Skip to content
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

Fix #543; divider width should match largest sibling #2108

Merged
merged 21 commits into from
Aug 4, 2024

Conversation

mathiscode
Copy link

This PR fixes #543 causing the divider to now be the same width as the largest sibling.

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just seeing a few issues.

  1. The width of the divider should be equal to the width of the widest sibling, not cousins. Sorry that was definitely not clear in the original issue, as the screenshot depicted a different case (see [3] below).

Here, the first divider should only be as long as b2, not This is a longer thought:

image

Instead of using data-depth, I would suggest storing the parent's Path. That way you can uniquely identify all children of the divider's parent.

  1. The divider width should update dynamically as the user types. I suggest subscribing to the editingValueStore.

  2. When the divider is the only child in the second column of a table view, it should be set to the max width of all siblings and cousins (other col2 thoughts in the same table but a different row). (This was documented in the original issue here; sorry that wasn't clear from the beginning). Technically this case is what the screenshot in the original issue depicts. I will update it so it's clearer.

I would suggest storing the grandparent's Path so that you can uniquely identify all the grandchildren (col2 thoughts) within a divider's table.

src/constants.ts Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
raineorshine

This comment was marked as resolved.

@raineorshine

This comment was marked as resolved.

src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/LayoutTree.tsx Outdated Show resolved Hide resolved
@mathiscode

This comment was marked as resolved.

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diff looks good now, thanks!

A couple cases where the divider width is not updated. I confirmed that editingValueStore is updated in these cases, so maybe the effect is firing before React has re-rendered. This would cause setStyle to measure the DOM before it has been updated.

Case 1

Paste a longer thought as a cousin of the divider.

Col2, only-child divider's width is not updated.

Case 2

Delete the longest thought.

Col2, only-child divider width does not update.

src/components/LayoutTree.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Show resolved Hide resolved
src/components/Divider.tsx Show resolved Hide resolved
src/components/Divider.tsx Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
Comment on lines 66 to 67
useEffect(setStyle, []) // eslint-disable-line react-hooks/exhaustive-deps
editingValueStore.useEffect(setStyle)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to pass the dependencies that are expected here. No need to override the linter from what I can tell.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this because we need setStyle to be called on the initial render before editingValueStore takes over, but providing the expected dependencies causes a loop and it gets called repeatedly. I've created an experimental branch to show this in action:

https://github.com/mathiscode/em/tree/experiment/543-divider-width-exhaustive-deps

I'm definitely open to any suggestions for a more optimal solution, but this does seem to solve the immediate problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like dividerRef is not a stable reference, even if dividerRef.current is. Learned something new! :)

So you should be able to fix it with [dividerRef.current, path].

@mathiscode
Copy link
Author

Pasting/deleting should now be fixed with 01a5b25. I'll work on replacing the current approach with a more React-centric one.

@raineorshine
Copy link
Contributor

raineorshine commented Jul 11, 2024

Thanks, confirmed.

Using setTimeout works, although it's not totally ideal since it may trigger a second paint. We recently switched to useLayoutEffect in a hook that measures the DOM in order to re-render before the first paint. I played around with it and the only other thing I could get to work was a useSelector on the cursor value (which changes on paste and delete) + useLayoutEffect. I'm not sure if it's any better performance than setTimeout. I'm fine sticking with that for now.

@raineorshine
Copy link
Contributor

raineorshine commented Jul 12, 2024

I noticed another case where the divider width is not updated: If you set the cursor on the divider or its parent and refresh the page, it assumes the minWidth on load.

@mathiscode
Copy link
Author

I noticed another case where the divider width is not updated: If you set the cursor on the divider or its parent and refresh the page, it assumes the minWidth on load.

Good catch! 8c0df72 should resolve this - it feels a little hacky, we add an additional setTimeout in the case of isCursor, but it seems effective and shouldn't introduce any regressions.

@raineorshine
Copy link
Contributor

raineorshine commented Jul 22, 2024

I'm not really comfortable with a hack here unless we have exhausted all other possibilities. The problem with introducing more timeouts is that it creates more side effects out of the React render cycle, which can lead to more problems in the future. I'm already making an exception for the other setTimeout.

We should try to first identify the cause of the problem. Why is the width updated after navigating, but not updated after refresh? What is different about the refresh case? Usually it indicates that there is some other bit of state that needs to be subscribed to. My guess is that the divider is loaded first because it is under the cursor, and there is nothing to make it re-render once its siblings load.

@mathiscode
Copy link
Author

I'm not really comfortable with a hack here unless we have exhausted all other possibilities. The problem with introducing more timeouts is that it creates more side effects out of the React render cycle, which can lead to more problems in the future. I'm already making an exception for the other setTimeout.

We should try to first identify the cause of the problem. Why is the width updated after navigating, but not updated after refresh? What is different about the refresh case? Usually it indicates that there is some other bit of state that needs to be subscribed to. My guess is that the divider is loaded first because it is under the cursor, and there is nothing to make it re-render once its siblings load.

I'll find another approach and let you know what I come up with.

@mathiscode
Copy link
Author

I found that watching for changes to thoughtIndex is enough to set the width after the siblings are loaded in. I was also able to remove the setTimeout for the editingValueStore, and technically it will update after editing even without subscribing to editingValueStore now, but it's not immediate so keeping that effect makes for better UX. Let me know if this is a better or worse solution. 23d5f1e

@raineorshine
Copy link
Contributor

That should be fine. We could subscribe to just the thoughts that affect its width (siblings + cousins), but at the cost of selector performance. And there are probably not too many thoughts being rendered that aren't siblings or cousins anyway.

}
}

useEffect(setStyle)
useEffect(setStyle, [setStyle, thoughtIndex])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setStyle is not a stable ref, so this will cause useEffect to trigger on every render. This is probably masking a missing dependency.

The thoughtIndex dependency is good because at least we're getting the additional render that is needed when the thoughts load.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I'm not even sure why I did that. I pushed the fix, although it didn't affect the number of times setStyle got called, but everything seems kosher now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusingly, the ref container is not actually a stable reference, only the object inside:

dividerRef // unstable
dividerRef.current // stable

So the number of times setStyle is called hasn't changed because it's still getting called on every render.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only issue with that is I get this complaint:

dividerRef_dependency

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If dividerRef is unstable, and dividerRef.current is an invalid dependency, then what do we do? I must be missing something.

Maybe you can find the answer online. We can't be the first ones to run into this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. I'll let you know what I come up with.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

56171ea - After some trial and error, I think the cleanest way I can come up with is to just bring back the eslint-disable for exhaustive-deps, and at that point we don't really need to rely on the dividerRef at all since it was just added to satisfy the eslint warning. Not having the ref as a dependency calls setStyle the same number of times as using dividerRef.current so we still have stable dependencies and preserve the correct behavior on page load with the divider selected. Does this seem effective enough to you, or maybe I'm missing something else?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, we can suppress the linter for now. There is a chance that the DOM may update and setStyle will not be triggered, but I don't have a test case for it.

FYI It looks like the recommended solution is to switch from an object ref to a callback ref, but my experiments didn't show that it made much difference. It will be good to keep these in mind if we run into this issue in a different context though:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a comment in 7e7e4b5 so that future developers can understand why we suppressed the linter.

@raineorshine
Copy link
Contributor

I'm going to merge this, but I'll note the limitations of this implementation here:

  1. react-hooks/exhaustive-deps is suppressed on the useEffect, which may result in setStyle not being called when the DOM changes (unable to reproduce)
  2. Determining if the divider is an only-child could be done in React, but it is done with direct DOM access, which is more fragile.
  3. There is a high performance cost to using multiple DOM selectors in setStyle which is called on every keystroke. Accumulating refs and passing them through React would obviate the need for DOM selectors.

@raineorshine raineorshine merged commit ac2d280 into cybersemics:table-jay Aug 4, 2024
2 of 3 checks passed
raineorshine pushed a commit that referenced this pull request Aug 4, 2024
Co-authored-by: Raine Revere <raine@cybersemics.org>
resolve #543; divider alignment and width calculation
resolve further issues with #543 discussed in PR 2108#pullrequestreview-2156802356
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants