-
Notifications
You must be signed in to change notification settings - Fork 9
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
Determine strategy for making Forgo extensible without expanding framework core #54
Comments
I'm gonna stop saying "hooks-y", because I definitely don't want to bring React's style of hooks to Forgo. Maybe a better term would be 'lifecycle event listeners', as distinct from the existing lifecycle methods. I don't know what sort of API I'm thinking of here, I'm just kinda talking out loud as I think about handling the example use case. |
Excellent ideas.
Totally agree.
I am thinking you mean something quite different from the following. Can you give an example? function onclick() {
await getSomeData();
args.update();
}
Good idea.
React mostly uses it to inject state and state-modifying-functions (setters, reducers etc) into components. This is essential for React, because the essence of React is view = function(data) - and a change to data automatically should update the view. Forgo's view updates are manually triggered. So, would it suffice to do a regular import (of a file such as the following) into the component? // some-context.ts
export const myContext = {
status: "active",
secondsInactive: 0
}
Can you give an example of what this will look like?
I need to understand these a little more. Will comment this week. |
Auto rerenders
This is something I'm lifting from Mithril's autoredraw system. The idea is that when a user clicks a button, you probably want to rerender. And if the button click triggers a network request, you probably want to wait for the request and then rerender*. If that's what you want nine times out of ten, it's nice if the framework (or an add-on library) does it for you. What does it solve? 1) Reduced chance of bugs from some code path not including Put another way: Manual rerenders, though boilerplate-y, are better than the framework taking exclusive control of renders and trying to infer updates from state changes (like other frameworks). But I think inferring updates from DOM events is a good way to split the difference. I'm undecided on if it makes sense in core, but if not I'd like if Forgo could allow an add-on library to implement it. On the verbosity front, let's take your example: async function onclick() {
await getSomeData();
args.update();
}
return <button onclick={onclick}>Click me!</button> With at Mithril-esque system, this would just look like: return <button onclick={getSomeData}>Click me!</button> Less code to read, less to go wrong. This is a basic case. But if you throw in some conditionals, a try/catch, etc., then you amplify the chances that some code path won't correctly call A user could implement this behavior today in Forgo: // I guess this could just be async with `await cb()`, but this keeps synchronous stuff synchronous
function autoRedraw(cb, args) {
return () => {
try {
const result = cb();
// Test if result is a Promise
if (Promise.resolve(result) === result) {
result.finally(() => args.update());
} else {
args.update();
}
} catch {
args.update();
}
};
}
...
return <button onclick={autoRedraw(getSomeData, args)}>Click me!</button> The downside is this has to be applied by hand at every call site, for behavior you probably want across your whole app. The alternative with today's Forgo is for Forgo could allow libraries to implement this if component had a method to modify attributes before they're put onto the DOM. Something like {
transformAttr(name, value, args) {
if (name.startsWith('on')) {
return autoRedraw(value, args);
}
return value;
},
render() {...}
} Then you could imagine a library exposing this the same way forgo-state accepts a component and returns a component with modified lifecycle methods. Pair this with wanting click handlers to run inside the component's error boundary and we've got two examples where modifying DOM node attributes is useful. * Mithril was built before the Promise/A+ spec was ratified, so the only async stuff Mithril autoredraws for is its own built-in requests library Contexts
This is how I'm handling it now (typically with forgo-state so I don't have to track what has to rerender), but static exports only work if the use case is amenable to a singleton. If you need something reusable (a repeatable component hierarchy, or a reusable application pattern), current Forgo requires prop drilling. Context APIs allow context to be created at arbitrary points in the component hierarchy, rather than as app globals. I think it's also suboptimal to make state an app-wide singleton as a default. Sometimes that turns out to be a mistake, and then it's a whole big thing to change how it works. If the default is to make a Context, then if you suddenly need two independent copies of some component hierarchy, that's no problem. The one big hassle with Contexts is types. A component expects to be run with certain Context values available, and I'm not sure how to express that in a type-safe way, guaranteeing a component is only instantiated in the correct context. Example use case: in my app, I have a big list of cards with buttons on them. Click a button and refetch updated data from the server. I don't want the user to click buttons too fast, because when the network request finishes the layout in the affected region will shift, so whats under their thumb could change as they're reaching to press it. So I want to disable all buttons in the affected region while the request is in flight, plus 500ms afterwards. There are 3-4 levels of component in between the top of the region and all of the buttons, so prop drilling the disabled state + the disable function would be a pain. And once I implement this, I'd like it to be reusable across parts of my app. I don't want to make this a global singleton, because I want to gray out disabled buttons, and graying out the whole UI would be confusing, and also the user may want to go click things that have nothing to do with the affected region while the request is in-flight. With Contexts I could make a MagicButton component that watches the context, replace all my stock I don't think Forgo needs Contexts to come with magic auto-rerender behavior. Since rerendering an ancestor rerenders all descendants, descendants don't usually need to be reactive to Context state. And if they do, that's what forgo-state is for. It's just avoiding the prop drilling that's needed. Runtime-modifiable lifecycle events
This is probably not a good API, but it at least demonstrates the idea I'm thinking of: const MyComponent = () => {
let socket;
return {
mount(_props, args) {
const interval = setInterval(doStuff, 1_000);
args.unmount(() => clearInterval(interval));
socket = makeWebsocket();
args.unmount(() => socket.close());
},
render() {...}
}
} The idea is that setup + teardown get put in the same place. Right now, it's easy to forget to implement teardown. This starts to enable more reusable logic: const MyComponent = () => {
return {
mount(_props, args) {
useInterval(doStuff, 1_000, args);
},
render() {...}
}
} Now, intervals get torn down by default, no user discipline needed. Questions here:
If we take this pattern to its logical extreme, the component API may look like this: const myComponent(_props, args) => {
const {mount, unmount, afterRender, error} = args;
return () => {
return <p>Hello, world!</p>;
}
} I'm not necessarily saying the component API should look like this, I'm more putting it on the table to ask, just how open or closed are we to API changes in general? Are we considering Forgo's design "mostly done", or an evolving attempt to embody certain traits? An alternative is we decide that wrapping components is the go-to way to go, and we make that super streamlined. That works for some things, like the interval where we want behavior but not data. But it doesn't work as well for the websocket example, where a component would want access to the socket to use in e.g., click handlers. |
Excellent examples and writeup. I've always seen forgo's core as a tiny, lower-level lib with mostly explicitly triggered behavior. For example, that's why we have
I would strongly vote in favor of "Forgo could allow an add-on library to implement it". (As opposed to it being in core.)
Good idea. And I don't think this will be difficult to implement. I can take a look at this, but you might have a better idea of what's needed. But do let me know if you want me to.
I agree that this could be quite useful, and your example is convincing. Not sure if there'd be a way to avoid prop drilling without contexts, but Context itself may not be hard to implement. We just need to decide on it. I am in favor of coming up with a proposal and then adding this feature; unless you think we can avoid prop drilling with some other technique. Btw, can this (your example of disabling buttons) be done with forgo-state?
This is another interesting idea. The question we need to ask ourselves is whether our events are multicast or not. As of now they're just a function - do we need them to be lists of functions? If we go that route, we'd also want a want to detach a function from an event, and also a way to list the functions currently attached to an event. If we decide to go this route, we could assume that events can have a list of attached functions - and the current structure (which we already have) could form the first function in that list. We should also keep in mind that it may not make sense for render to be "multicast" - what are we going to do with two renders? Alternatively, we could throw away the current structure entirely - and make component construction a run-time thing. function MyComponent() {
return (component: ComponentBuilder) => {
component.mount(() => {
const interval = setInterval(doStuff, 1_000);
component.unmount(() => clearInterval(interval));
const socket = makeWebsocket();
component.unmount(() => socket.close());
});
component.render(() => <div>Hello world</div>);
};
} This wouldn't be too difficult to implement, because it doesn't touch the hard parts (bookkeeping) of forgo. |
Attr transformer
Go ahead and look at it. As I presently understand the problem, the important part is something that receives an attr name + value and returns the value Forgo will set on the node. Watch out for idempotency (#32). Maybe we only want to call the transformer method if the inputs have changed? If we call it every render, it'll return a referentially-inequal modified value every render. Contexts
I can't think of an a way around it with the existing API. Everything comes down to, if you want an out-of-band way to do this, you still have to communicate which specific out-of-band instance (key, symbol, etc.) you want to reference. Which comes back down to prop drilling or asking the framework to do the drilling for you. To toss out an alternative, instead of putting contexts into core, a library could implement the feature if forgo had an API to walk the component ancestry and a stable component identifier. const contexts = new WeakMap<ChildNode,unknown>();
export function set(ref, key, value) {
contexts.set(args, value);
}
export function lookup(args, key) {
return contexts.get(args.element) ?? lookupContext(forgo.getParentRef(args, key))
}
...
function MyComponent(_props, args) {
libcontext.set(args, 'foo', 123);
const bar = libcontext.lookup(args, 'bar');
} I don't like that this leaks I do like that removing implementation details from forgo-state already calls for an API to walk the component ancestry, and that this aligns with the vision of an extensible core, rather than putting another feature into core.
Sorta. In the current form of my scenario, there are known, hardcoded regions that need to be managed, so I think forgo-state would work for the case in front of me (where But if I wanted to independently affect dynamic regions it'd get trickier (where each forgo-state might do it using Lifecycle eventsfunction MyComponent() {
return (component: ComponentBuilder) => {
component.mount(() => {
const interval = setInterval(doStuff, 1_000);
component.unmount(() => clearInterval(interval));
const socket = makeWebsocket();
component.unmount(() => socket.close());
});
component.render(() => <div>Hello world</div>);
};
} This style feels good to me. What do we get from nesting the arrow function within This style starts to feel a bit like SolidJS, which might be a good direction to go. Their approach seems like a logical evolution of what major frameworks have turned into. I like this style more than trying to add events on top of the existing monolith object API. We might need to make some multicast and some not and eat that as a documentation issue. |
These are all separate proposals - we could discuss them in their own threads. |
I'm going to close this, since we've got the separate issues for each idea that came out of this. |
There will be bountiful cases where an app wants some extended version of what Forgo does today, but since Forgo aims to be simple and small, we want to arrive at a stable core that doesn't need constant tinkering and expansion to support new use cases.
What's the best way to facilitate this?
forgo-state is a good example of a framework extension. It's effective at its job, but I don't think it's a repeatable pattern. It has a lot of knowledge about Forgo's internals, which is both cumbersome and a barrier for non-core developers to do something similar. A hypothetical forgo-mobx library seems intense to implement right now.
Some examples of ideas requiring more than Forgo does today:
await
callbacks and rerender when they returnsetInterval()
, I have to addmount
+unmount
methods and wire up both of those, plus the place I actually use the interval, touching three separate places of code. Right now it's not possible to make that setup/teardown reusable, short of creating a whole wrapped component. Ifargs
supported something akin to event listeners, that'd be easy for libraries to build on.A few things I'm thinking through:
args.element
?The text was updated successfully, but these errors were encountered: