bikeshaving / crank

The Just JavaScript Framework

Home Page:https://crank.js.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

crank-ref cannot focus element synchronously (We need a way to access elements after they’ve been inserted into the DOM)

spiffytech opened this issue · comments

🐛 bug report

I have a component that includes a <textarea /> that I want to focus on creation. If I use a crank-ref callback, the element does not focus unless I wrap the callback in a setTimeout.

💻 Code Sample

Doesn't work

function TextAreaTest() {
  return (
    <div>
      <!-- lots o' code here -->
      <textarea crank-ref={(el: HTMLTextAreaElement) => el.focus()} />
    </div>
  );
}

renderer.render(<TextAreaTest />, document.body);

Works

function TextAreaTest() {
  return (
    <div>
      <!-- lots o' code here -->
      <textarea crank-ref={(el: HTMLTextAreaElement) => setTimeout(() => el.focus(), 0)} />
    </div>
  );
}

renderer.render(<TextAreaTest />, document.body);

Hmmm this is probably because of ref callbacks firing a little too early. They seem to be firing after DOM node creation rather than when they’re inserted into the parent. I will think about what we can do, and maybe overhaul the timings of these callbacks, but in the meantime you can continue using a setTimeout() or requestAnimationFrame() callback to mitigate this problem.

Also, for this specific use-case, you may wish to try the autofocus attiribute(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautofocus) instead, though apparently this doesn’t work too well.

I noticed the same behavior when yielding and using schedule(..).

This didn't work because the node was not yet connected to the dom:

let node = yield <input ... />;
node.focus(); // node isn't connected to the dom yet

Also, this didn't work for the same reason:

this.schedule(node => {
  node.focus(); // node isn't connected to the dom yet
});
yield <input ... />;

To work around the timing issue I chose to monitor the isConnected property:

this.schedule(node => {
  const handle = setInterval(() => {
    if(node.isConnected) {
      node.focus();
      clearInterval(handle);
    }
  }, 100);
});
yield <input ... />;

Edit: I'm using crank v0.3.6

@waynebaylor That’s a resourceful but non-ideal workaround! We don’t want to have to poll the browser for rendering 😓

TL;DR: I’ll try to add a new callback method called complete() which fires when nodes are rendered into the DOM.

I’ve been doing a bit of thinking about the timings of the crank-ref callback prop and the schedule() callback, and I think ultimately we’re going to have to add a separate callback method to contexts which fires when a component’s children are rendered into the actual DOM. I’m thinking we can call it complete(), mirroring an internal renderer method. The schedule() fires when a component’s children have fully rendered, but has no knowledge of when these DOM nodes are actually inserted into the DOM. The tricky thing is that a component can have async siblings, which defer the insertion, and the parent nodes mights themselves have their insertion deferred as well because of some other async thing.

I didn’t think this was a big deal when I initially designed this API, but it turns out, it definitely is, because DOM functions and methods like getComputedStyle() and node.focus() do not work on disconnected DOM ndoes.

After a little pondering I realized that the soonest we can confirm that a component’s children are connected to the DOM is when the rendering’s initiator (renderer.render() or Context.prototype.refresh()) completes. At the end of each of these functions, we are 100% guaranteed to have everything in the DOM and visible, so allowing callbacks to be fired at this point for components makes the most sense. However, note that this callback can fire an indefinite amount of time later than the current schedule() callback fires, because of the async sibling/parent issue I mentioned earlier.

You may be wondering why we don’t just change the behavior of schedule() to fire at this later point in time. The answer is that I still value the immediacy of the schedule() callback; I especially like that it can be used to render a sync generator component twice synchronously, for when you need to get access to a DOM node and render again. Additionally, not all DOM mutations require that the element be connected, and firing them in a complete callback might be later than is necessary. I also have plans to allow the schedule() callback to be async, which will allow components to defer the insertion of DOM nodes, for <SuspenseList /> like coordination.

So the plan is, there’s going to be three callback APIs. The crank-ref prop, which fires immediately as DOM nodes are created, schedule(), which fires when a component finishes rendering, and complete() which fires when the entire process of rendering has completed.

I have to do a little bit of thinking/sketching about how this should actually be implemented (there’s some complications when you have multiple concurrent renderings in different parts of the tree, and I have to figure out where callbacks get stored) but I’ll try to get it into a release soonish (this month). If you have any thoughts or questions, or have any pressing need that I should try to get this implemented sooner, lemme know.

@brainkim Agreed, it's not ideal, but I like it because it explicitly says what I'm waiting for.

So the plan is, there’s going to be three callback APIs. The crank-ref prop, which fires immediately as DOM nodes are created, schedule(), which fires when a component finishes rendering, and complete() which fires when the entire process of rendering has completed.

Seems like a good plan to me 😄

TL;DR: I’ll try to add a new callback method called complete() which fires when nodes are rendered into the DOM.

As I understand, Crank aims to provide an intuitive vanilla-JS experience when writing components.
So then, maybe it makes more intuitive sense to continue execution after yield only when the said yielded element renders?

Currently provided (in-docs) solution with this.schedule(() => this.refresh()) IMHO seems unnecessarily complicated and hard-to-follow for a problem this simple, and with the proposed this.complete() solution Crank risks to fall into reimplementing the same lifecycle-hooks pattern it desperately tries to avoid.

So the plan is, there’s going to be three callback APIs. The crank-ref prop, which fires immediately as DOM nodes are created, schedule(), which fires when a component finishes rendering, and complete() which fires when the entire process of rendering has completed.

From a newcomer's perspective, it feels weird when some lifecycle events are done using native control flow statements and others are done with callbacks.
I feel like it would be more consistent to have a single "proper" way of handling this and leave edge-cases to callbacks. Maybe it makes sense to repurpose this.schedule() to execute code right after the injection of a yielded element instead of after rendering, or, in other words, switch purposes of const = yield and this.schedule/this.complete, and leave the crank-ref as is?

I, currently, don't see any real-world use for executing code right after creating an element (yield <div>) before it renders (as it is implemented now). In my experience, it was that almost everything either goes before the rendering or after everything renders, and very rarely was it needed to put some logic in-between those two.

Maybe I'm missing something obvious here, so please correct me if I'm wrong.

P.S. Crank.js is amazing in its approach to components, so I look forward to see how the issue is resolved in the end.

I’m dogfooding this and thinking we might need a better name for this part of the lifecycle that’s not “complete.” What I want to say is that the rendering process has finished, insofar as changes have propagated to the current root, but the problem is that this conceptually conflicts with the notion of “cleanup,” and having two methods which both mean “at the end” is mildly confusing. The difficulty is that I need a name that articulates “after render,” without creating an afterRender() method. But the problem with most one-word names that would fit the bill like “complete” or “finalize” is that they imply the callback is run when components are unmounted.

The best I can come up with right now is composite(), as in “compositing.” This is a term borrowed from computer graphics and visual effects, and as far as I can tell, it seems to signify the final stage of any rendering process or pipeline, though the term seems to apply to things like calculating pixel values for images with different alpha values overlaid on top of each other (alpha compositing), or things like transforming painted layers (the browser’s layout-paint-composite concept). I think repurposing this word would be nice. I would want to continue to keep that sort of rhyming with the internal Renderer method, so this would go with a renaming of renderer.complete() to renderer.composite(). I’m mostly fine with this breaking change because I do want to change up the renderer internal methods a bit too.

@brainkim What about connected?

@waynebaylor I would want it to be connect(), insofar as all the method names are present-tense. That name kinda makes sense I guess? It doesn’t seem to have a finish rendering sorta ring to it to my ear though. But I like its genericness so maybe.

@Raiondesu

Currently provided (in-docs) solution with this.schedule(() => this.refresh()) IMHO seems unnecessarily complicated and hard-to-follow for a problem this simple, and with the proposed this.complete() solution Crank risks to fall into reimplementing the same lifecycle-hooks pattern it desperately tries to avoid.

I appreciate the general sentiment, which is that we should use as much of control flow and the natural generator lifecycle as we can, but I still feel like we’ll need these one-off lifecycle methods, unfortunately. There is a reason why we needed the this.schedule(() => this.refresh()) method, which is that assigning yield expressions to variables just doesn’t work well in sync generator components. When you write const node = yield <whatever /> in a sync generator, the execution stops right before the node is actually assigned (imagine the interpreter moving right-to-left and stopping exactly on the yield keyword, which is pretty confusing when you’re trying to access the node in a callback which shares the scope of the variable. It’s really unfortunate, and we also can’t just resume the generator component again so that the variable is assigned, because it would have to keep going until the next yield/return/throw, because it’s a synchronous generator function and it can‘t just suspend somewhere else like async generator components. See this issue for more discussion about that #28.

There’s also the issue of reusable logic. Using control flow and assignment only really works in the generator component itself, whereas I wanted to allow developers to write reusable functions which are passed component contexts and can respond to updates, perform actions on the rendered nodes or whatever. Havin schedule() and cleanup() methods helps with this, even though all that logic could be written directly in async generator bodies with try/finally and yield assignments.

As for why we need two methods that allow you to hook into “after update” or “after render,” the reason we need these separate methods is that the point at which a component’s child DOM nodes are created and rendered, and the point at which these DOM nodes are added to the parent can be very different. If it’s the first render, and the component has async siblings, Crank will wait for all the siblings before it actually does the task of inserting the nodes into the parent.

I’ve also been thinking about having the schedule() and cleanup() methods optionally take async functions, so that we can defer the insertion and removal of DOM nodes for components, after they’ve been rendered in and out. This would allow 1. SuspenseList style coordinations of siblings and 2. async exit animations.

It’s a bit messy for sure, but the process of having declarative, async/stateful representations of trees is complex as well. I think the trick is to just name all the parts of rendering in a clear way. Part of this for me means avoiding before/after naming conventions, for reasons I can get into if you’re interested in my thoughts on naming.

The problem is that this stuff is all on the frontier, insofar as no one has really conceptualized or given names to whatever JSX rendering is beyond the React people. You have no idea how many times I tried searching for stuff like state tree algorithms or whatever, trying to look for existing terminologies and conceptualizations.

I ended up choosing the name flush(). The flush() method has been added in 0.4.0 (still need to document it though).