nettybun / haptic

Explicit reactive web rendering in TSX with no compiler, no virtual DOM, and no magic. 1.6kb min+gz.
MIT License
79 stars 2 forks source link

README suggestions #16

Open mindplay-dk opened 3 years ago

mindplay-dk commented 3 years ago

A couple of suggestions for the example in the README.

  1. Add /** @jsx h */ to the top - just to be explicit, and so newbs don't have to spend time figuring out how to change the default in a .babelrc or something.

  2. The when example was a bit confusing, since it's actually unnecessary - a wire with a ternary expression would work just as well. Newcomers might perceive this example as "ternaries are not available", which would be a bad, since those are idiomatic to most JSX libraries. I would go with a simple ternary here, which demonstrates reactive expressions - and maybe a separate example with 3 options, maybe color-choices in a drop-down?

  3. I'd like to see an example of a computed expression here as well - maybe just put the state.text($).length behind a computed? I actually forget what those even looked like. It's such an important feature, so it would be good to cover this in the example, so people are exposed to it right away.

  4. I immediately wanted to add autofocus to that input - which doesn't work, and I guess it can't "just work", since the elements aren't in the DOM at the time when it's being applied? From there, I start wondering about life-cycle events and can I manually focus the input after it's mounted... So this one might be worthy of a separate issue and further discussion.

I only had a short time playing around, as I wasted too much time trying to get CodeSandbox to work properly with .tsx - but this looks really promising and I'm super excited to try this out!

Where are we on map? I had a basic, working implementation in my own toy Sinuous clone here which might help you get the ball rolling - presumably it would need something like the snabbdom diff algorithm?

I know you have quite a bit of work to do on tests and such, but map is likely all that's all that's missing for me to start porting some of my example projects from Sinuous to Haptic, which I would love to do. (If I can find the time, I might try to prototype an implementation of map myself.)

Great work, man! So stoked to see where this goes. πŸ˜„

nettybun commented 3 years ago

Thanks for the tips!

  1. It makes sense but I've never seen one in the wild... once in a CodeSandbox maybe. I'd be tedious to put it on every file, so while I understand it's a potential pitfall for beginners, I think a better approach is to provide a cloneable starter-kit repo since I very much want people to hit the ground running with a good tsconfig.json, eslintrc.json (with TS support), .eslintignore, building via esbuild, etc etc. That's tons for a beginner to figure out on their own, so it'd provide a better out of the box experience.

  2. They're actually really different, but you're right that I should document that better. Ternaries repeat all of their work each run: every time a signal triggers the wire to run the ternary, it'll create a brand new DOM tree (even if that branch has rendered before), then removing the old DOM tree and adding the new. The content can be exactly the same and it still does this because I don't do any DOM diffing... In when(), I cache the DOM nodes by the "T"/"F"/etc key so they're only ever rendered once. This is huge when wires are patched in the DOM too, because they're paused/resumed when the content is shown/hidden rather than destroyed and recreated. Imagine a page router or something that loads huge trees of components and wired values - a ternary would destroy the whole app every page route change.

  3. Oh haha see this commit https://github.com/heyheyhello/haptic/commit/0c8a980feb1b9709c7698784e624907411b7c57b. I'll think of a good example for a computed but honestly I've built entire apps and never used them. I know they're important in some cases, and I'm happy Haptic's are lazy/cheap to have, but I don't know if beginners will think in terms of computed when they start coding... Maybe if I make an Excel analogy to set the mood first...

  4. Never used an autofocus before but I'll test it out and see what other frameworks do! Thanks for the heads up I never would have tried that.

  5. I've read the sinuous/map source code but don't understand it enough to port it. Haven't needed it yet. I have API compatibility with Sinuous' h functions like add, rm, etc but not the observable functions like cleanup, root. I'll have to see if "cleanups" are actually necessary since Haptic doesn't have that concept yet. Appreciate that you went down the rabbithole trying to improve the diffing in map - I'll read your code to help understand how it works, but I'll likely focus on #3 first.

nettybun commented 3 years ago

Re: when(): https://github.com/luwes/sinuous/issues/115 is a good thread and is where I came up with the initial when() implementation in Sinuous. @ryansolid had some good discussion points on the topic. He also said:

... Be careful with state updates updating offscreen nodes in ways that would break them

Which literally was the primary reason I added wirePause and wireResume - it was for when(). I needed to be able to pause an entire sub-tree of an app to stop offscreen state updates.

For a readme intro though I can use a simple ternary. I think I was just excited to use when() because its usecase drove a lot of Haptic's development to be honest

nettybun commented 3 years ago

Update the state docs to include more computed-signal examples and be a bit more introductory (slowly introducing topics one by one): https://github.com/heyheyhello/haptic/blob/main/src/state/readme.md

mindplay-dk commented 3 years ago

The caching feature in when is interesting, I didn't realize that's how it worked.

It's actually not what I expected, and I can see this potentially creating a few problems.

Let's say I have a set of tab panels and use when to switch between the panels, this has some implications:

  1. Memory overhead - if I have 10 tabs each with 1000 table rows, this is potentially a problem. The modern browser is notoriously memory hungry.

  2. External state - if, say, one tab posts information to a server, and another tab reads information from a server, if the second request involves something like a JOIN across data created by the first request, you will need to add some sort of manual cache invalidation, via a computed or something.

  3. The element of surprise - to my knowledge, no other framework works like this. You switch between components in React for example, they lose their state.

That last one worries me in particular. The way I like to explain UI state in general, is I like to distinguish between application state and control state - the difference being, application state is relevant for as long as the application runs, while control state is only relevant while a control exists.

The native DOM example is a dropdown selector: you don't expect to switch to a different tab in your application or something, and come back to find the dropdown is still somehow dropped-down.

The userland example (for which you'd build a custom component) is something like a date-picker, where, again, if you switch away from a tab, you don't expect to come back and find the date-picker is still opened to some random month you had navigated to before deciding to switch to a different tab. The "is open" and "current month" states are only relevant while the control is visible, and it is instance-dependent, so you would use local state for that.

Caching in this manner kind of mucks up that distinction. What used to be control state will now hang around indefinitely. In a different context, your components would have a life-cycle where the control state resets when they disappear from the view, but suddenly this works differently. If you have reusable components with control state, and you introduce when, you're going to run into this issue, which might be quite surprising.

That's not to say the feature is useless by any means! I'm sure it's very useful for some cases. Just that it seems risky to encourage use of this as a default. It's probably something that should be brandished as an optimization? With the memory and component state caveats. As with anything, you don't reach for performance optimizations unless you have a performance problem - "premature optimization" and all that...

I've read the sinuous/map source code but don't understand it enough to port it.

Me neither. I couldn't make heads or tails of it, which was part of the reason I decided to just conceptualize of the problem myself and implement a solution.

I don't know how you get by without it. Never use lists?? πŸ˜„ ... so I will probably try to port mine at some point, and we'll see how that pans out. πŸ™‚

nettybun commented 3 years ago

Interesting points, thanks. I think I was eager to figure out how to switch content with minimal work. when() does that, but it doesn't scale and - worse - it's surprising behaviour, I agree. It should be renamed to convey caching and saving DOM nodes like cachedTreeRouter(). It'll also need methods for clearing cache elements... I'll think about how to iterate on it.

I wrote when() (initially back in Sinuous) to avoid obviously-unnecessary element recreation in ternaries. It's a problem faced by all reactive DOM-based libraries. Take this example and imagine a signal counts up from 0 to 100:

<div>
  {wire($ =>
    data.count($) > 5
      ? (log('render T'), <p>There are more than 5 clicks</p>)
      : (log('render F'), <p>Clicks: {wire(data.count)}</p>)
  )}
</div>

It works, but it's bad.

In when(), it renders each branch once (1) and only does work when the condition changes; moving count from 5 to 6.

I went to see how Solid's compiler handles ternaries. It's really good stuff! Take a look at this...

image

It memos the condition expression into a new signal, such that $c only ever fires when moving from 5 to 6. The val() signal is hidden from the ternary. This means the ternary only runs once per branch, which is perfect. It's just as little work as when().

I can do this in Haptic too by making a signal based on an expression:

/** Create a signal based on an expression */
const signalFrom = <T>(cond: ($: SubToken) => T): Signal<T> => {
  let s: Signal<T> | undefined;
  wire(($) => {
    const c = cond($);
    if (!s) s = signal.anon<T>(c); // First run
    else if (s() !== c) s(c);
  })();
  return s!;
};

<div>
  {wire($ =>
    signalFrom($ => data.count($) > 5)($)
      ? (log('render T'), <p>There are over 5 clicks</p>)
      : (log('render F'), <p>Clicks: {wire(data.count)}</p>)
  )}
</div>;

Note I can't use a computed-signal here because they're lazy and only calculate the conditional when they're read, so they're for a different use case; signalFrom() is basically the same as Sinuous' computed() (which, I've tried to avoid since I see it as wasteful but in this case it's useful).

It'll run the T branch and F branch once each πŸ˜„ The wire created by signalFrom will also be cleared in the parent ternary wire, which is good.

I'll put this in the stdlib and use it on the front page of the readme after I think about it a bit more... Let me know any thoughts.


I don't know how you get by without it. Never use lists??

Yeah lol. Honestly I never use more than like 10 elements so I just recreate them every run πŸ™ƒ. I have a solution for #17 though.

mindplay-dk commented 3 years ago

Honestly I never use more than like 10 elements so I just recreate them every run πŸ™ƒ

So your todo-list app will have a limit of 10 todos? I guess that motivates people to complete their todos πŸ˜‚

Jokes aside, I love computed - I like the fact that I can think and organize a program with the simplicity of thinking about and organizing a spreadsheet. "This changes, that updates" - just really works for me. πŸ™‚

I like the idea that I can largely organize state and derived state at the top of my program - as opposed to computing derived states on the fly, inline with JSX. Of course, regularly functions can do that - but the cascading updates that flow from signals to computed signals, it just seems like a natural extension of the reactive mindset.

Even if it's a little wasteful and unnecessary at times, I still like it, because consistency - it seems to reduce the amount of refactoring needed, like when something that was just a function needs to become reactive for some reason... it already is.

For me, it's like, if this is how I've decided to manage my state, that's how I'm going to manage all my state.

(the inconsistencies is one of the things that bug me more than anything about React - you have this use-case? use this pattern. oh now this other thing gets introduced? now you want that pattern instead... state management is terribly inconsistent in React, and I think that's one of the reasons there's a new state management library every two weeks...)