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

[rdom] Issue understanding $klist #466

Open
arnaudchenyensu opened this issue May 5, 2024 · 4 comments
Open

[rdom] Issue understanding $klist #466

arnaudchenyensu opened this issue May 5, 2024 · 4 comments

Comments

@arnaudchenyensu
Copy link

Hi @postspectacular ,

I'm experimenting a bit with rdom and I slightly modified the example from rdom-svg-nodes to change the color of the currently selected circle but I'm having different behaviors (commented in the following code sample) that I don't quite understand:

import { defAtom } from "@thi.ng/atom";
import { svg } from "@thi.ng/hiccup-svg";
import { $compile, $klist } from "@thi.ng/rdom";
import { fromAtom } from "@thi.ng/rstream";
import { repeatedly } from "@thi.ng/transducers";
import { random2, type Vec } from "@thi.ng/vectors";

const WIDTH = 600;
const NUM = 4;
const R = 20;

// define atom of NUM random points
const db = defAtom({
  clicked: -1,
  pts: [...repeatedly(() => random2([], R, WIDTH - R), NUM)]
});

// reactive subscription for atom changes
const $db = fromAtom(db);

$compile([
  'div',
  {},
  svg(
    {
      width: WIDTH,
      height: WIDTH,
      viewBox: `0 0 ${WIDTH} ${WIDTH}`,
      onmousemove: (e: MouseEvent) => {
        const clicked = db.deref().clicked
        clicked !== -1 && db.resetIn(['pts', clicked], [e.clientX, e.clientY])
      },
      onmouseup: () => db.resetIn(['clicked'], -1),
      ondblclick: (e: MouseEvent) =>
        db.swap((state) => ({
          ...state,
          pts: [...state.pts, [e.clientX, e.clientY]]
        })),
    },
    $klist(
      $db.map(({ clicked, pts }): [number, number, Vec][] => pts.map((p, i) => [clicked, i, p])),
      "g",
      {
        fill: 'white',
        stroke: 'black'
      },
      ([_clicked, i, p]) => [
        "circle",
        {
          r: R,
          cx: p[0],
          cy: p[1],
          onmousedown: (e: MouseEvent) => {
            db.resetIn(['clicked'], i)
            db.resetIn(['pts', i], [e.clientX, e.clientY]);
          },
          // fill: clicked === i ? 'grey' : 'white' // -> the circle will stay grey after the first click
          fill: $db.map(({ clicked }) => clicked === i ? 'grey' : 'white') // -> correct solution
        }
      ],
      ([clicked, i, p]) => {
        const fill = clicked === i ? 'grey' : 'white'
        // return `${p}-${i}-${fill}` // -> the circle will be duplicated on click
        return `${p}-${i}` // -> correct solution
      }
    ),
  )
]).mount(document.getElementById("app")!);
  1. The selected circle will stay grey even after a mouse release but I was expecting clicked to be updated automatically because it's coming from $db.map(...) (just like the circle position is automatically updated from p):
fill: clicked === i ? 'grey' : 'white'
  1. The $klist doc says that "Like a hash, the key value MUST represent an item's current value such that if the value changes, so does the key." So I thought that I needed to make the fill color part of the key but it will actually duplicate the circle:
return `${p}-${i}-${fill}`

Can you help me understand my mistakes?

Thank you in advance 😄

@postspectacular
Copy link
Member

Hi @arnaudchenyensu - sorry not much time to go into more detail right now, but I've prepared a new (more minimal) example with some hopefully helpful comments:

Demo:
https://demo.thi.ng/umbrella/rdom-klist/

Source:
https://github.com/thi-ng/umbrella/blob/develop/examples/rdom-klist/src/index.ts

20240506-rdom2.mp4

Please let me know if that answers your Q...

@arnaudchenyensu
Copy link
Author

arnaudchenyensu commented May 12, 2024

Hi @postspectacular,

Thank you for your example, it's very helpful to understand how to use your libraries!

However, I think that my questions are still unanswered. Even though what I wrote is not good practice, I don't understand why in my question 1, the circle stays grey after a mouse release. In your example v1 you are using the function highlight(sel, i) but in my example I am directly evaluating the expression fill: clicked === i ? 'grey' : 'white'. Does it mean that attributes are updated only if a function is given?

And in my question 2, I have duplicate circles even though my keys are similar to the ones in your example v1 (${p}-${i}-${fill} vs selection|index|item).

Am I missing something?

@postspectacular
Copy link
Member

Hi @arnaudchenyensu - sorry this is causing so many headaches. I took another closer look at your example and the issue has actually nothing to do with your key function, but with the way you're updating the atom in your onmousedown handler.

To remind ourselves once more: each time the atom state is being updated, the atom notifies its watchers, which in this case is the $db = fromAtom(...) subscription, which then in turn immediately propagates the new state downstream to its own subscribers: in your case this is first the $db.map(...) subscription used to create items for $klist() and then inside $klist another subscription which actually performs the list management & item updates...

Now looking at your onmousedown handler:

onmousedown: (e: MouseEvent) => {
    db.resetIn(['clicked'], i)
    db.resetIn(['pts', i], [e.clientX, e.clientY]);
}

These two separate state updates are also triggering two $klist updates, which internally happen partially in async functions, since the mounting & unmounting of rdom components is async. And it's this async behavior of the list update which here causes an out-of-sync issue with the 2 successive updates and break the klist item diffing...

There're (at least) 2 easy solutions to this though (either of them will suffice):

  1. Use the syncRAF() rstream operator to throttle such multiple state updates and synchronize with the next requestAnimationFrame and so only cause a single component update (using the latest state)... i.e. change your $klist input into this:
import { syncRAF } from "@thi.ng/rstream";

// ...

$klist(
    syncRAF($db).map(({ clicked, pts }): [number, number, Vec][] => pts.map((p, i) => [clicked, i, p])),
   // ...
)
  1. Use the defTransacted() wrapper to perform transacted state updates to the atom itself, i.e. by changing your onmousedown handler like so:
import { defTransacted } from "@thi.ng/atom";

// ...

onmousedown: (e: MouseEvent) => {
    const tx = defTransacted(db).begin();
    tx.resetIn(["clicked"], i);
    tx.resetIn(["pts", i], [e.clientX, e.clientY]);
    tx.commit();
}

I've just double checked that each of these two approaches does fix the issue you're having. I will make a note (or accept a PR) to update the docs for $klist to point out these potential pitfalls and solutions. Thanks for your patience :)

@arnaudchenyensu
Copy link
Author

Hi @postspectacular ,

Thank you for your quick reply, it's working a lot better now 😅

Is there any drawbacks from using syncRAF? Because it seems more "forgiving" when the codebase starts to get bigger.

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

No branches or pull requests

2 participants