Giter VIP home page Giter VIP logo

haptic's Introduction

Haptic

Reactive web rendering in TSX with no virtual DOM, no compilers, no dependencies, and no magic.

It's less than 1600 bytes min+gz.

import { h } from 'haptic';
import { signal, wire } from 'haptic/state';
import { when } from 'haptic/stdlib';

const state = signal({
  text: '',
  count: 0,
});

const Page = () =>
  <div>
    <h1>"{wire(state.text)}"</h1>
    <p>You've typed {wire($ => state.text($).length)} characters</p>
    <input
      placeholder='Type here!'
      value={wire(state.text)}
      onInput={(ev) => state.text(ev.currentTarget.value)}
    />
    <button onClick={() => state.count(state.count() + 1)}>
      +1
    </button>
    <p>In {wire($ => 5 - state.count($))} clicks the content will change</p>
    {when($ => state.count($) > 5 ? "T" : "F", {
      T: () => <strong>There are over 5 clicks!</strong>,
      F: () => <p>Clicks: {wire(state.count)}</p>,
    })}
  </div>;

document.body.appendChild(<Page/>);

Haptic is small and explicit because it was born out of JavaScript Fatigue. It runs in vanilla JS environments and renders using the DOM. Embrace the modern web; step away from compilers, customs DSLs, and DOM diffing.

Developers often drown in the over-engineering of their own tools, raising the barrier to entry for new developers and wasting time. Instead, Haptic focuses on a modern and reliable developer experience:

  • Writing in the editor leverages TypeScript to provide strong type feedback and verify code before it's even run. JSDoc comments also supply documentation when hovering over all exports.

  • Testing at runtime behaves as you'd expect; a div is a div. It's also nicely debuggable with good error messages and by promoting code styles that naturally name items in ways that show up in console logs and stacktraces. It's subtle, but it's especially helpful for reviewing reactive subscriptions. You'll thank me later.

  • Optimizing code is something you can do by hand. Haptic let's you write modern reactive web apps and still understand every part of the code. You don't need to know how Haptic works to use it, but you're in good company if you ever look under the hood. It's only ~600 lines of well-documented source code; 340 of which is the single-file reactive state engine.

Install

npm install --save haptic

Alternatively link directly to the module bundle on Skypack or UNPKG such as https://unpkg.com/haptic?module for an unbundled ESM script.

Packages

Haptic is a small collection of packages. This keeps things lightweight and helps you only import what you'd like. Each package can be used on its own.

The haptic package is simply a wrapper of haptic/dom that's configured to use haptic/state for reactivity; it's really only 150 characters minified.

Rendering is handled in haptic/dom and supports any reactive library including none at all. Reactivity and state is provided by haptic/state. Framework features are part of the standard library in haptic/stdlib.

Motivation

Haptic started as a port of Sinuous to TS that used TSX instead of HTML tag templates. The focus shifted to type safety, debugging, leveraging the editor, and eventually designing a new reactive state engine from scratch after influence from Sinuous, Solid, S.js, Reactor.js, and Dipole.

Hyperscript code is still largely borrowed from Sinuous and Haptic maintains the same modular API with the new addition of api.patch.

haptic's People

Contributors

brecert avatar nettybun avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

haptic's Issues

Add list support

I tried to prototype an implementation of map here - and it "works", but not the way we need.

https://codesandbox.io/s/try-haptic-0-10-e9le1?file=/src/map.js

The problem here is the return newNodes statement at the end. The work is already done, so I don't need the framework to do anything here... But if I don't return anything, it wipes out the DOM - whereas, if I do return the list of nodes, it replaces the entire range of nodes, undoing the work that diff just did, making the whole thing pointless.

In my own toy Sinuous clone, this approach worked well, because reactions don't return anything that gets automatically applied to the DOM - only creation is handled at the framework level, updates are performed by the reactive listener on the DOM directly.

I'm not sure how to make this fit into Haptic.

If we have to return newNodes and let Haptic do the work, then it would need to be able to diff the result - integrating diff at that level. But that would have performance implications, I think? We would be diffing also in cases where the intent is to actually just replace, which would work, but almost definitely isn't acceptable for performance (and bundle size) reasons, right?

Any idea how this would fit with Haptic?

Missing explicit declaration for signal() return-type

How do you define a type for a signal-based model?

The best pattern I could find thus far is something like this:

const Todo = (state: { title: string, done: boolean }) => signal(state);

type Todo = ReturnType<typeof Todo>;

So declare a constructor function, and then derive a type from it's return-type.

The disconnect here seems to be that, while signals can only be created as an object, only the derived individual properties of the returned type currently have an explicit generic type?

https://github.com/heyheyhello/haptic/blob/57f989a1ff1a0c62cfced149056c75646a3c8f63/src/state/index.ts#L274-L275

I don't actually need the constructor function, it's only there so I can derive a type - in this simple case, I'd prefer to have the static type only, without the run-time footprint.

Can we have a generic type for this? Something like:

type Todo = Signals<{ title: string, done: boolean }>;

Syntax too cryptic?

I know it's early days for this project, and perhaps too soon to critique anything - but I'm following along, as I've been very interested in Sinuous for a while now, and this promises to be a "rewrite of Sinous", and I would love to see those ideas evolve from here. ๐Ÿ™‚

I'm looking at the example, and frankly I'm finding it a bit obscure. One of the things Sinuous has going for it, is it's reasonably intuitive to get started with - I really wouldn't like to see that get lost in a rewrite.

So here's my (full disclaimer:) really opinionated comments, take'em or leave'em. ๐Ÿ˜

import { wS, wR, v$ } from '../src/wire/index.js';

โ˜ These functions need proper names.

I think I pointed you to dipole already? This has fairly intuitive naming, once you're used to the terminology: observable(value) creates an observable value, computed(() => { ... }) creates a computed reaction, reaction(() => { ... }) creates reactive effects, and so on.

I also like that Dipole's observables make reading and writing explicit with .get() and .set(value) methods - it requires slightly more work on the keyboard, but makes it much easier to skim through a large chunk of code and spot where the reads/writes are taking place. Opinionated, but readable code is more important than writable code: you only have to write it once, but you have to read it many times, your coworkers have to read it, and so on.

const data = wS({
  text: '',
  count: 0,
  countPlusOne: wR(($): number => {
    console.log('Conputing countPlusOne');
    return data.count($) + 1;
  }),
  countPlusTwo: wR(($): number => {
    console.log('Conputing countPlusTwo');
    return data.countPlusOne($) + 1;
  }),
});

โ˜ These APIs need to be consistent.

Why does wS accept an object with many observable values, and reactions mixed in?

The text and count observables, and the countPlusOne and countPlusTwo reactions, do not have any relationship with each other, as far as I can figure? I don't think there's any compelling reason to pack unrelated initializations into a single function - so I'd prefer these two functions were more consistently both singular factory functions, something like:

const data = {
  text: observable(''),
  count: observable(0),
  countPlusOne: reaction(($): number => {
    console.log('Conputing countPlusOne');
    return data.count($) + 1;
  }),
  countPlusTwo: reaction(($): number => {
    console.log('Conputing countPlusTwo');
    return data.countPlusOne($) + 1;
  }),
};

This is already more readable, consistent and intuitive - it should simplify typing with TS as well.

    <p>Here's math:
      {wR(($) => data.count($) < 5
        ? Math.PI * data.count($)
        : `Text: "${data.text($)}" is ${data.text($).length} chars`)}
    </p>

โ˜ Too many dollar signs ๐Ÿ’ธ

I think I get what you're trying to do here, and I love the fact that the reactive context isn't somehow established by some magic behind a dark curtain.

Side story: I recently made a friend try out Sinuous, and the magic actually bit him. He had a parent component that contained a reactive expression - and in a child component that was used in that reactive expression, he thought he could safely read from an observable, in a section of the child component's code that wasn't reactive. Which you absolutely can - but the reactive expression in the parent component will pick up the usage and associate it with updates of the parent, which caused unexpected updates. It took him a very long time to fix this. That's nasty and frustrating learning curve, and it's something anybody is likely to run into at first.

Dipole has the same problem, btw.

Fixing that would be awesome.

But the syntax here is a bit ugh - manually passing that same argument again and again everywhere.

Also, you end up unnecessarily reading the same observables more than once. (which, yeah, you could assign the values to variables first, but then almost nothing dynamic would ever be just a single, elegant expression, so, ugh.)

Did you consider maybe something like this instead?

    <p>Here's math:
      {reaction([count, text], (count, text) => count < 5
        ? Math.PI * count
        : `Text: "${text}" is ${text.length} chars`))}
    </p>

This is even more explicit - though perhaps too explicit, and creates subscriptions on things that might not actually be used, so, meh. You might could get around that with even more explicitness:

    <p>Here's math:
      {reaction([count], count => count < 5
        ? Math.PI * count
        : reaction([text], text => `Text: "${text}" is ${text.length} chars`)))}
    </p>

This gives you the right subscriptions but, eh, that's not pretty.

Yeah, I'm not really sure what to suggest here. ๐Ÿค”

And this last thing:

    <input value={wR(data.text)}/>

โ˜ I know that's actually the same operation as the other reaction - that data.text is the reader function, and it'll receive the $ argument, so it's equivalent to the redundant wR($ => data.text($))... but it looks like two completely different things.

I know Sinuous does something similar, allowing observables to be used directly in expressions, where it'll create the reaction for you, but it's not a favorite feature of mine either...

I like consistent patterns in UI libraries - where you recognize operations intuitively, without parsing.

I don't know, maybe this is yet another thing you just have to get over - but it's worth thinking about learning curve and adoption rate. Not at all cost, of course - but it's not as much fun being the one guy who gets something, going through life struggling to get other people to get it, and I think that's really the key to React's success, and certainly to the introduction of hooks, even though those are absolutely horrendous and nasty behind the curtains.

I wouldn't want to trade off good or clean or correct for something magical that "looks cool", but is there something we can do to make this more intuitive and palatable?

I hope you don't find any of this too offensive, but feel free to tell me to f_ck off and mind my own business. ๐Ÿ˜„

Tests

The reactive state library has a lot of new features/fixes over previous versions. These should be tested. Here are some initial tests to have:

  • Errors during creation shouldn't impact global state, ID counters, or run counts
  • Mismatched sig($)/sig() should throw "Mixed" error
  • Mismatched sig()/sig($) should throw "Mixed" error
  • Transactions should only commit last value and defer calling wires
  • Adoption should impact consistency checks for sigRS/sigRP lists
  • Run count should be 0 on start even for computed-signals
  • Using a computed-signal in a wire shouldn't change sigRS/sigRP lists
  • Wire unsubscribe should reset all lists and disconnect signals
  • Running in a loop causes the "Loop ${wire.name}" error
  • Pausing then writing to a signal shouldn't run a wire
  • Pausing then writing to a signal and unpausing should run a wire
  • Pause/Unpause/Pause/Unpause shouldn't do any work
  • Tree of parent/children reactors should execute in the right sorted order by pruning children
  • Error recovery. Throwing an error shouldn't hurt other wires/signals
  • Patching of a single wire works via chaining
  • Patching works

SSR?

Does this library have an SSR output? If not, will there be in the future?

Mixing signals and computed-signals disallowed?

With the following example:

const value = signal("foo");

const computed = signal($ => value($) + "!!!")();

core($ => console.log(/*"signal:", value(),*/ "computed:", computed($)))();

value("bar");

If I uncomment the bit that prints the signal value, I get an error Mixed rp|rs - and presumably, this is about signals and computed-signals being disallowed in the same context?

Is this "by design"?

With Sinuous (or S.js) and Dipole, you can mix signals and computed-signals. As far as subscriptions go, they both represent a "stream of values", so it's not clear to me why it would be necessary to restrict this.

I am concerned we lose some of the explainability - I like to use a spreadsheet example to explain these concepts, as it's the most well-understood "functional/reactive programming language" there is, and it does not have any restrictions like this.

Imagine you had to refactor a spreadsheet, and something that was a value changes to an expression - if spreadsheets had this limitation, you can imagine how difficult it would be to get it back into a working state.

I'm concerned the same kind of problems will be inherent with this limitation.

Sinuous observables

  1. Would like to use sinuous observables how to patch it in api.patch? Any help appreciated.

  2. Are there any cleanup function when component unmounts?

Write proper introduction, documentation, and examples

There's no proper introduction to Haptic at all right now. I've put off writing documentation for a very long time. I need to also delete the examples/ folder.

It's blocked on porting styletakeout.macro from Babel to Acorn so I can have CSS-in-JS. I've never said this but, while not part of the library directly, CSS-in-JS is an important part of Haptic's values along with not being tied to heavy tools like Webpack/Babel.

It's an important part of showcasing examples; many reactive libraries show an example using React/Preact for a similar reason. It needs to be tangible and appealing in ways that my sandbox examples/ folder isn't.

Then I'll create a dedicated repo for examples (interactive, styled, literate programming) to introduce features.

Transaction doesn't atomically write to signals

https://github.com/heyheyhello/haptic/blob/aafd43884925e3b415c0942baa067f6c0acabfae/src/wire/index.ts#L258-L276

This isn't right.

signals.forEach((wS) => { 
  wS(wS.tV); // Write the signal, notifying all reactors. 
  delete wS.tV;
});

It'll call reactors one by one, who will each read signals, and those signals may be in the list of to-be-commited transactionSignals so the reactor will read an outdated value.

This is what other libraries do, and I got this code from Sinuous.

There's two options:

  • Write/Commit on the fly: I could have a flag that says we're in the process of committing a transaction, so any signal that is being read can check that flag and commit itself by first writing the wS.tV (wiresignal transaction value) to itself (TODO: Skip notifying reactors?) and possibly remove itself from the list of transactions signals (depending on the above TODO; I think if reactors are not skipped it'll lead to an infinite loop since the read is already occurring during a reactor run).

  • Defer reactor runs: Any signal that's writing/commiting it's wS.tV would place it's subscribed reactors into a global set, like transactionSignals instead of notifying. Only after all transaction signals have done this do we do the call out.

Developer bundle

There should be a dev bundle of Haptic. This can carry features that would normally be too costly in size or performance to include in the normal bundle. Here's a work-in-progress list of features:

  • Registries for both reactors and signals, listing everything that has been created and is active. This was once part of Haptic Wire directly as a WeakSet(), but a Set() would be iterable.
  • Viewing reactive elements on the page using a custom api.patch() method. I've done this before by wrapping all reactive elements in a <span> with a dashed border, but there must be a less-obtrusive way. Reactive attributes can be shown in a popover (i.e <input value={wR(data.text)} />).
  • Debugger sidebar that lists statistics about reactors and signals. I started this is /examples/reactorRegistry.ts but needs work.

Challenges:

  • Importing this will be difficult since it brings new/different copies of haptic/dom and haptic/wire that use a separate global state than the non-dev import. How will a developer consistently switch a dev build and back, maybe in package.json or import maps?
  • The debugger sidebar can't really use Haptic Wire. If it did, it would be debugging itself as well as the main page, which is confusing and very likely leads to infinite loops.

README suggestions

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. ๐Ÿ˜„

Typing issue with computed signals

Consider an example like this, in TypeScript:

const state = signal({
  foods: ["pizza", "beer", "cake", "more pizza"],
  numFoods: wire($ => state.foods($).length),
});

There's a problem with this: inference for the type of numFoods fails, because it is dependent on the type of foods, which hasn't been inferred yet. TypeScript doesn't seem to allow cycles between inferred members. (Even though this is not technically a cycle if you consider the individual property types, TypeScript appears to try to resolve the types via the inferred type of the object rather than it's properties, resulting in a cycle... or something?)

I could manually type out an interface for the signal call, but that's a bother - explicitly passing the types doesn't work, because the type of state would still be inferred:

const state = signal<{ foods: string[], numFoods: number }>({
  foods: ["pizza", "beer", "cake", "more pizza"],
  numFoods: wire($ => state.foods($).length),
});

I could manually type out the entire return-type - that works, but it's a real bother:

const state: { foods: Signal<string[]>, numFoods: Signal<number> } = signal({
  foods: ["pizza", "beer", "cake", "more pizza"],
  numFoods: wire($ => state.foods($).length),
});

I could break them apart like this:

const state = signal({
  foods: ["pizza", "beer", "cake", "more pizza"],
});

const computed = signal({
  numFoods: wire($ => state.foods($).length),
});

That works, but two collections isn't what I wanted.

I could wrap them in a function and tease out the inference that way:

const state = (() => {
  const state = signal({
    foods: ["pizza", "beer", "cake", "more pizza"],
  });

  const computed = signal({
    numFoods: wire($ => state.foods($).length),
  });

  return { ...state, ...computed };
})();

That also works, but, yuck...

I'm having two thoughts here.

๐Ÿ’ญ Having to create multiple, unrelated states at once might not be a good idea. I've pointed this out before, and I know you don't agree, because you're attached to the idea of object properties turning into debug-friendly names. I'm still not convinced of that, because the names will get mangled, and this won't save you when things fail in production - I'd prefer to find a more reliable approach to debugging.

๐Ÿ’ญ Alternatively, maybe the recommended idiom is to separate state from computed state, like in the two last examples - maybe you can argue that separating mutable from computed state is "a good thing".

If so, maybe we could have a separate helper, similar to signal, for computed state only - this would just save you the repeated wire calls, when creating your derived states, but maybe it provides a more explainable concept?

const state = signal({
  foods: ["pizza", "beer", "cake", "more pizza"],
});

const computed = computedSignal({
  numFoods: $ => state.foods($).length,
});

I'm not particularly fond of letting implementation issues drive design - but in this case, I suppose you could argue it's good to separate mutable state from computed state? ๐Ÿค”

(This issue is partially me thinking ahead to documentation/tutorials - it would be great if we didn't need to explain problems like this, and even better if users didn't have to run into them and look for a solution and explanation in the first place...)

Exporting more of the types

It'd be nice if all of the exposed types were exported in some way.
I often use types directly from libraries when writing functions and types and haptic does not currently export all of it's types.

Thanks to typescript, types typically aren't opaque so it's not essential to export every type that's exposed through functions and other objects, but it would be nice if they were,

I'm assuming they aren't exported because they aren't meant to be public or visible, or possibly that they are redundant types, and I think even if it was hidden from documentation and wasn't supported by the expected guarantees of semantic versioning it'd still be nice to have.

While specifying the types for each of the when status view functions here would be better, this is kind of the example usage I'm thinking about in this issue.

// These could be imported from `haptic/stdlib,`
// but they aren't exported so they need to be copied here for full compatibility
type Component = (...args: unknown[]) => El;
type El = Element | Node | DocumentFragment;

type AsyncStatus = "unresolved" | "resolved" | "rejected";
function Async<T>(promise: Promise<T>, views: Record<AsyncStatus, Component>) {
  const status = signal.anon<AsyncStatus>("unresolved");
  const data = signal.anon<T | null>(null);

  promise
    .then((res) => {
      status("resolved");
      data(res);
    })
    .catch((err) => {
      status("rejected");
      data(err);
    });

  return when(
    wire(($) => status($)),
    views
  );
}

3 years on

hey Garnet,

I was wondering, where did this project trail off? are you still coding? not much Github activity.

I really liked this idea and still end up on this page by various routes now and then.

last night, I kind of took the idea and ran with it - I have nothing that works, probably, haven't attempted to run anything, but I do have an interesting looking prototype of something that builds on your idea of explicit context passing... it seems like a good direction, but I don't know if I'm smart enough of have the time/energy to see it to completion. ๐Ÿ˜…

if you'd like to see some code, my DMs are open on Twitter. (I don't want to post this half baked idea in public yet.)

hope you're doing well :-)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.