ivi
ivi copied to clipboard
some questions (imperative libs, portals, virtualized list, routing)
hey @localvoid i have some questions, maybe these answers will turn into useful isolated wiki/demos/patterns for solving common tasks or React parallels.
imperative libs
let's say i want to inject uPlot or CodeMirror into an ivi component. in React i use something like a useRef to grab the dom node of the container and useEffect to then initialize the lib into it.
in ivi it looks like directives can be used for this, but they don't provide any cleanup/destroy/unmount callback, so can only serve to grab the dom element and store it in a local var, and then ivi's useEffect can be used to inject the imperative lib. then, data updates to these instantiated libs should be managed in the directive again (like updating codemirror instance content when selecting another file from a side-nav and fetching the source)?
looks like useUnmount might be the answer, but this only works at component level, so it i have a component where i have codemirror injected into a template node via a directive, i cannot make use of this if that template node is conditional like showEditor. i guess this just means it needs to be broken out into its own sub-component?
it would actually be cool to see an ivi playground written in ivi + codemirror, maybe built in a separate repo with iterative commits from basic/minimal scaffolding to more advanced hookups and optimizations. like domvm ;) https://domvm.github.io/domvm/demos/playground/#stepper1
portals
first use case: global modals, like i wanna portal a Form component into an overlay that's outside the component root that triggered the modal, but i still want to pass callbacks to it to handle data input and bring that data back to the modal-triggering component?
next use case: uPlot does not provide tooltip functionality out of the box, so if i'd like to build one as a React component, i can portal it into the uPlot's DOM and then use uPlot's setCursor hooks set up during init to update the position/content of the tooltip. (like shown below). is something like this possible with an ivi-managed Tooltip component? what would that look like?
https://codesandbox.io/s/serene-hermann-7qxzk1?file=/src/App.tsx
export default function App() {
const r = useRef<HTMLDivElement | null>(null);
const [plot, setPlot] = useState<uPlot | null>(null);
useEffect(() => {
let opts = {
width: 300,
height: 300,
series: [
{},
{
stroke: "red"
}
],
scales: {
x: {
time: false
}
}
};
let data = [
[1, 2],
[3, 4]
];
let plot = new uPlot(opts, data, r.current);
setPlot(plot);
}, []);
return (
<div className="App" ref={r}>
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
{plot && ReactDOM.createPortal(<div>Hi!</div>, plot.over)}
</div>
);
}
virtualized list
would be cool to have an idiomatic demo (equal height items), maybe custom overlay scrollbar like https://github.com/Grsmto/simplebar or something ivi-native. here's something i hacked together recently for uFuzzy filterable list with react-window and React list nodes being created via a highlighter callback: https://codesandbox.io/s/musing-mccarthy-pwx88g?file=/index.js
routing
does it exist? what would/could it look like?
thanks!
Hi,
I'll try to explain why I've published this library, so that you can understand its future direction, and why it was designed this way.
In the last several years I've tried to figure out how to solve some fundamental problems with "signal"-like libraries, like the issue that you've recently posted in js-framework-benchmark with fetching data from servers. It gets even worse when building something like a game UI as we will need to query data on each frame and it would be a bad idea to make every value in a game state as a "signal". I've learned how to deal with problems like that in an efficient way in "signal"-like libraries, but at the same time I've come to a conclusion that in majority of use cases it just not worth to spend a lot of time figuring out how to build it in "the most efficient" way. Stupidly simple f(state) => UI solution without any complex optimizations will be fast enough for majority of web apps, it just needs to have an efficient diffing engine. And we just need to understand its limitations and choose the right tool for the job.
So, I've decided to publish ivi 2.0, it is actually an old implementation that I did ~4 years ago when I've experimented with templates. I've removed a lot of experimental features like when I've wasted a lot of time trying to implement a gesture disambiguation (similar to Flutter) with synthetic events. I've reduced its API to the minimum, so that it can be completely stabilized without making any changes in the future and just publish it as a finished solution. Pretty much everything that isn't currently implemented right now I would consider outside of the scope for this library, it is not a complete solution for making web apps, there won't be any ivi routers, etc. It is more like an efficient alternative to libraries like lit-html with some extra features like lightweight stateful components.
The only major feature that is currently missing and that I'd like to add is SSR/Hydration, and I'd like to add hydration without adding any extra code to the core module, so that it could be implemented in a separate module ivi/hydrate. Don't know if it is possible, but since template cloning is already implemented as a some form of hydration, I think that it shouldn't be that hard.
DevTools and VsCode integration are also missing, but it is highly unlikely that I am going to implement it in the future. I'll probably add a template compiler written in Rust and SWC plugins.
imperative libs
The simplest solution to this problem would be to wrap this libraries into web components. With complex components like CodeMirror it is probably one of the best solutions, as we can isolate its styles and reduce css selectors overhead.
And then we can use it like this:
htm`code-mirror .prop=${value}`
But it is definitely possible to implement it without web components, and you're right, it should be just wrapped into a stateful component instead of injecting it via a directive.
In general, it has the same problems as React if we try to add conditional side effects, so we can just wrap conditional side effects in components. I just wish that it would be possible to implement something like positional memoization in javascript.
portals
first use case: global modals
It can be implemented with components and side effects. I'll add an experimental implementation tomorrow in a separate package, but I am not sure about its API, maybe something like this:
// portalContainerNode is a stateless node
// portal is a component factory that will render into portal
const [portalContainerNode, portal] = createPortal();
// With such API, root nodes won't be tightly coupled with portals and it
// would be possible to use custom schedulers for portals.
updateRoot(
createRoot(document.getElementById("overlay")),
portalContainerNode,
);
const App = component((c) => {
return (condition) => htm`
div.App
${condition ? portal(htm`span 'Hi!'`) : null}
`;
});
next use case: uPlot does not provide tooltip functionality out of the box
The simplest way would be to implement it as a side effect:
import { component, SRoot, useUnmount, VAny } from "ivi";
import { createRoot, disposeRoot, updateRoot } from "ivi/root";
import { htm } from "ivi/template";
import uPlot from "uplot";
const App = component((c) => {
let _portal: SRoot;
let _portalInput: VAny;
useUnmount(c, () => { disposeRoot(_portal, false); });
const createPortal = (container: Element) => {
_portal = createRoot(container);
if (_portalInput !== void 0) {
updateRoot(_portal, _portalInput);
}
};
const portal = (v: VAny) => {
_portalInput = v;
if (_portal !== void 0) {
updateRoot(_portal, v);
}
};
let _plot: uPlot;
const ref = (e: HTMLElement) => {
let opts = {
width: 300,
height: 300,
series: [
{},
{
stroke: "red"
}
],
scales: {
x: {
time: false
}
}
};
let data = [
[1, 2],
[3, 4]
];
_plot = new uPlot(opts, data as any, e);
createPortal(_plot.over);
};
return () => (
portal(htm`div 'Hi!'`),
htm`
div.App $${ref}
h1 'Hello CodeSandbox'
h2 'Start editing to see some magic happen!'
`
);
});
updateRoot(
createRoot(document.getElementById("root")!),
App(),
);
virtualized list
I wouldn't consider the example below as an idiomatic windowing solution, but it is the simplest one :)
import { component, invalidate, List, VAny } from "ivi";
import { findDOMNode } from "ivi/dom";
import { createRoot, updateRoot } from "ivi/root";
import { htm } from "ivi/template";
interface Entry {
id: number;
text: string;
}
const getEntryId = (e: Entry) => e.id;
interface VirtualListProps<T> {
rows: number;
height: number;
data: T[];
renderChild: (entry: T) => VAny,
}
const _max = Math.max;
const _min = Math.min;
const _floor = Math.floor;
const VirtualList = component<VirtualListProps<Entry>>((c) => {
let _props: VirtualListProps<Entry>;
let _startOffset = 0;
const onScroll = () => {
const rootNode = findDOMNode<Element>(c)!;
const next = _max(0, (_floor(rootNode.scrollTop / _props.height) & ~0b11) - 5);
if (_startOffset !== next) {
_startOffset = next;
invalidate(c);
}
};
return (props) => {
_props = props;
const { height, data, renderChild } = props;
const displayCount = _min(data.length - _startOffset, props.rows);
const endOffset = _startOffset + displayCount;
const rows = data.slice(_startOffset, endOffset);
return htm`
div.virtual-list @scroll=${onScroll}
div
~padding-top=${(_startOffset * height) + "px"}
~padding-bottom=${((data.length - endOffset) * height) + "px"}
${List(rows, getEntryId, (e) => renderChild(e))}
`;
};
});
const data: Entry[] = Array(10000);
for (let id = 0; id < data.length; id++) {
data[id] = { id, text: Math.random().toString() };
}
updateRoot(
createRoot(document.getElementById("app")!),
VirtualList({
rows: 20,
height: 50,
data,
renderChild: (e) => htm`div.virtual-list-item =${e.text}`,
}),
);
routing
I think that it would be better to create a complete solution with routing and other stuff in a different library that uses ivi for DOM updates, as I want to keep its API as small as possible and avoid any experimentation. In React-land there are so many different routers now :)
Here is a quick example how to implement portals for modals with components:
import { component, useUnmount, VAny, List, Component, invalidate, VComponent } from "ivi";
import { createRoot, updateRoot } from "ivi/root";
import { useState } from "ivi/state";
import { htm } from "ivi/template";
let _nextPortalEntryId = 0;
export interface PortalEntry {
readonly id: number;
v: VAny;
}
const getPortalEntryId = (entry: PortalEntry) => entry.id;
const renderPortalEntry = (entry: PortalEntry) => entry.v;
const invalidatePortalContainers = (containers: Component[]) => {
for (let i = 0; i < containers.length; i++) {
invalidate(containers[i]);
}
};
export const createPortal = (): [VComponent, (v: VAny) => VComponent] => {
var _entries: PortalEntry[] = [];
var _containers: Component[] = [];
return [
// portal container
component((c) => {
_containers.push(c);
useUnmount(c, () => { _containers.splice(_containers.indexOf(c), 1); });
return () => List(_entries, getPortalEntryId, renderPortalEntry);
})(),
// portal
component<VAny>((c) => {
const entry: PortalEntry = {
id: _nextPortalEntryId++,
v: void 0 as any,
};
_entries.push(entry);
useUnmount(c, () => {
_entries.splice(_entries.indexOf(entry), 1);
invalidatePortalContainers(_containers);
});
return (v) => {
entry.v = v;
invalidatePortalContainers(_containers);
return null;
};
}),
];
};
const [portalContainer, portal] = createPortal();
updateRoot(
createRoot(document.getElementById("overlay")!),
portalContainer,
);
const App = component((c) => {
const [visible, setVisible] = useState(c, false);
const onMouseEnter = () => { setVisible(true); };
const onMouseLeave = () => { setVisible(false); };
return () => (
htm`
div.App
span
@mouseenter=${onMouseEnter}
@mouseleave=${onMouseLeave}
'Portal Example'
${visible() ? portal(htm`span 'portal'`) : null}
`
);
});
updateRoot(
createRoot(document.getElementById("root")!),
App(),
);
- Changed
useEffect()behavior to make it easier to create DOM side effects - Added
@ivi/portalpackage.
imperative libs
ivi component + CodeMirror: https://codesandbox.io/p/sandbox/ivi-codesandbox-63wywc?file=%2Fsrc%2Fmain.ts
import { component, useEffect } from "ivi";
import { createRoot, updateRoot } from "ivi/root";
import { htm } from "ivi/template";
import { EditorView, basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
const App = component((c) => {
let _container: HTMLElement;
let _editor: EditorView;
const ref = (e: HTMLElement) => { _container = e; };
const codeMirror = useEffect(c, () => {
_editor = new EditorView({
extensions: [basicSetup, javascript()],
parent: _container,
});
})();
return () => htm`
div.App
div.CodeMirror $${ref}
`;
});
updateRoot(
createRoot(document.body),
App(),
);
portals
global modals: https://codesandbox.io/p/sandbox/ivi-portal-gwgk0b?file=%2Fsrc%2Fmain.ts
import { component } from "ivi";
import { createRoot, updateRoot } from "ivi/root";
import { useState } from "ivi/state";
import { htm } from "ivi/template";
import { createPortal } from "@ivi/portal";
const [portalContainer, portal] = createPortal();
updateRoot(createRoot(document.getElementById("overlay")!), portalContainer);
const App = component((c) => {
const [visible, setVisible] = useState(c, false);
const onMouseEnter = () => {
setVisible(true);
};
const onMouseLeave = () => {
setVisible(false);
};
return () =>
htm`
div.App
span
@mouseenter=${onMouseEnter}
@mouseleave=${onMouseLeave}
'Portal Example'
${visible() ? portal(htm`span 'portal'`) : null}
`;
});
updateRoot(createRoot(document.getElementById("app")!), App());
uPlot overlay: https://codesandbox.io/p/sandbox/ivi-uplot-g44m74?file=%2Fsrc%2Fmain.ts
import { component, useEffect } from "ivi";
import { createRoot, disposeRoot, updateRoot } from "ivi/root";
import { htm } from "ivi/template";
import { createPortal } from "@ivi/portal";
import uPlot from "uplot";
const App = component((c) => {
const [portalRoot, portal] = createPortal();
let _plot;
let _plotContainer: HTMLElement;
const plotContainerRef = (e: HTMLElement) => (_plotContainer = e);
useEffect(c, () => {
let opts = {
width: 300,
height: 300,
series: [
{},
{
stroke: "red",
},
],
scales: {
x: {
time: false,
},
},
};
let data = [
[1, 2],
[3, 4],
];
_plot = new uPlot(opts, data as any, _plotContainer);
const root = createRoot(_plot.over);
updateRoot(root, portalRoot);
return () => {
disposeRoot(root, false);
};
})();
return () =>
htm`
div.App
div.PlotContainer $${plotContainerRef}
${portal(htm`div 'Hi!'`)}
`;
});
updateRoot(createRoot(document.getElementById("app")!), App());
awesome, i'll play around with these in the next couple days.
It is more like an efficient alternative to libraries like lit-html with some extra features like lightweight stateful components.
i see a lot of similarities with ivi as what i tried to do with DOMVM, except faster/better. e.g. single-init-closure with private state that minifies well and returns a rendering fn. manual invalidation* if needed (similar to domvm's vm.redraw() / React's forceUpdate).
in contrast to React with its absurd rules of hooks, stale closures, and perf footguns.
* yes, you can probably simulate this with some setInvalidate(Math.random()) useState thing
@localvoid i see there's no longer an explicitly optimized textContent for in HTML Template Language. is it safe to assume that it's now detected/handled automatically, so all below are equiivalent?
renderChild: (e) => htm`div.virtual-list-item =${e.text}`,
renderChild: (e) => htm`<div class="virtual-list-item">${e.text}</div>`,
renderChild: (e) => htm`
<div class="virtual-list-item">
${e.text}
</div>
`,
No, it is an equivalent to HTML .textContent={} property. Internally, both parsers will generate the same node for intermediate representation:
https://github.com/localvoid/ivi/blob/6fb350f531607d693a8f4bfd2bdd8bc83840f10b/packages/ivi/src/template/ir.ts#L37-L42
{
type: IPropertyType.Value,
key: "textContent",
value: 0, // expr index
static: false,
}
In the ivi template language ={} works as a shortcut for .textContent={}, and HTML template language doesn't have any shortcuts:
htm`<div class="virtual-list-item" .textContent=${e.text} />`
afaik .textContent will always create and replace the existing text node. last time i checked (a while ago!) it was quite a bit faster to do parent.firstChild.nodeValue = 'bar' or parent.firstChild.data = 'bar' after the initiial creation via parent.textContent = 'foo'.
does ivi handle this optimization? (dbmonster is a bench where this made a big difference in the past).
Yes https://github.com/localvoid/ivi/blob/a7116793c662417e9d8d32fc4cfffadb6ae23959/packages/ivi/src/index.ts#L492-L496
But I guess there is a bug, it doesn't check firstChild if it has a null value.
@localvoid playing with portal...
disposeRoot() is gone after refactor. did anything replace it?
RE examples in: https://github.com/localvoid/ivi/issues/42#issuecomment-1475224768
Replaced with unmount()
Portals implementation that I've described above won't work in SSR context. React also doesn't provide universal wrapper for such use cases, and Vue also requires workarounds for portals. So I guess it is not that important in majority of use cases.
Portals implementation that I've described above won't work in SSR context
not a problem; this is 100% SPA.
lack of frontend routing on the other hand will become an issue. would be cool to see how you expect to implement one externally. i assume nested routes will piggy-back on contexts?
something with dynamic segments like /foo/123abc/bar/987, doing it as hash would work as well #/foo/123abc/bar/987
would be cool to see how you expect to implement one externally. i assume nested routes will piggy-back on contexts?
Depends on the complexity of an SPA. I would prefer less complexity and less abstractions if isn't necessary for an application.
A lot of SPAs can be implemented with basic routing that handles all clicks with a global event listener, so that we can just render simple <a> elements without any components or hooks like this.
I think that it is possible to add an ivi adapter to something like TanStack router (with contexts), but in my opinion all this abstractions are completely unnecessary for the vast majority of small/medium-sized SPAs. And with super complex SPAs that has a ratio of DOM elements per Component ~1 it doesn't matter how fast is diffing algo, the cost of abstractions will be the major bottleneck, so it would be better to just choose some other solution that is better suited for complex SPAs with global scheduling, suspense, error boundaries, etc.
i'm trying to do an arbitrary data drill-down SPA, so there's definitely a need to be able to share links to specific views that have several variables encoded in the url which get inserted into a chain of iterative queries + views that need to happen to display the correct thing. i'll see how far i can get with what's currently implemented.