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: SSR Combobox inner ref lost #7663

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

snowystinger
Copy link
Member

@snowystinger snowystinger commented Jan 24, 2025

Closes #7250

Added a SSR test which reproduces the bug. Haven't determined cause yet.

Best guess/working theory is that we let React render into the fake dom for collections. Usually, the last thing to render is the actual dom, so the ref is set correctly in that case. However, in this case, there appears to be an extra render in the fake dom, but not in the real dom, when I have a state change around the ComboBox.

I don't know how to verify this yet, nor how I could fix it if it were happening. At least, not without a custom renderer, which seems a bit heavy.
I attempted to remove the ref from everything in the CollectionBuilder/Document to no avail.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@nwidynski
Copy link
Contributor

nwidynski commented Jan 24, 2025

@snowystinger Took some time as well to look at this. My first intuition was also the fake DOM, but after noticing the issue being fixed by splicing the "non-collection" content from the builder, i was getting suspicious. Especially since only <ListBox /> content should have the required <Collection /> wrapper to even be hitting the fake DOM.

After additional investigation, it appears the issue is stemming from the switch in rendering strategies after initial hydration. The switch causes refs attached inside template to loose connection after moving to the portal. I tried assigning a stable key for both template and createPortal, but also to no avail.

The issue can only be fixed by maintaining a consistent strategy throughout the render cycle. (e.g. memoizing the useIsSSR() at hydration or by using template in both scenarios). I'm unsure whether this is possibly related to facebook/react#13563 or intended behavior.

In regards to the issuer objective, we currently calculate menuWidth with the rects of inputRef and buttonRef, so fixing this issue won't have impact on that. Unfortunately I can relate to the expectation of var(--trigger-width) to be dependent on triggerRef. Maybe we rename or even consider calculating based off triggerRef while clamping if smaller than input + button width as a courtesy. I feel like this could help avoid confusion moving forward 👍

@nwidynski
Copy link
Contributor

nwidynski commented Feb 3, 2025

@snowystinger Just pinging here in case it was missed. I can pick this up if you lack resources at the moment 👍

@snowystinger
Copy link
Member Author

Any help is appreciated, i'm hoping to get a chance to look at this one and [RAC] Table components do not play well with Suspense this week with some of the team.

* feat: auto-adjust menu width for custom triggers

* fix: test assert label compatibility

* fix: rules of react

* feat: popover minWidth for custom trigger elements

* fix: use resize observer

* chore: reset combobox story
@snowystinger snowystinger marked this pull request as ready for review February 7, 2025 03:21
@rspbot
Copy link

rspbot commented Feb 7, 2025

@rspbot
Copy link

rspbot commented Feb 7, 2025

@@ -43,6 +43,8 @@ const hiddenFragment = typeof DocumentFragment !== 'undefined' ? new DocumentFra
export function Hidden(props: {children: ReactNode}) {
let isHidden = useContext(HiddenContext);
let isSSR = useIsSSR();
// eslint-disable-next-line react-hooks/exhaustive-deps
let wasSSR = useMemo(() => isSSR, []);
Copy link
Member Author

Choose a reason for hiding this comment

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

See comment here for why #7710 (comment)
Concern being that we'll now always render into template if it's an SSR app. This may come with a performance hit, we'll need to figure out a way to benchmark this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, a quick MDN check tells me a template element without shadow root basically is a DocumentFragment.
Do you have specific concerns on what may cause reflow or repaint or is it just sanity check for now?

I was also chewing on whether useMemo may be unsuited here, as React may discard the cache in some scenarios causing our issue to reappear. Maybe a ref would be the better alternative.

Copy link
Member Author

Choose a reason for hiding this comment

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

sanity check mostly

even though it's just a document fragment, the nodes would be bigger in memory and there may be more to the logic of parenting/validation/etc, so it may be slower to add and remove nodes even without the consideration of reflow or paint

That's a good call out about the useMemo, I'll update it to a ref

@@ -92,6 +92,27 @@ export const Popover = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pop
let isHidden = useIsHidden();
let {direction} = useLocale();

// We can set minWidth to the trigger width as a courtesy for custom trigger elements.
Copy link
Member Author

Choose a reason for hiding this comment

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

is this something we want to do?

Copy link
Member Author

Choose a reason for hiding this comment

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

decided it's not, if i have a large trigger but want a smaller popover, then I can't do that now

Copy link
Contributor

Choose a reason for hiding this comment

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

I purposefully spread the users style over the minWidth to allow for overrides in case someone needs a smaller popover.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah yes, that is correct.

Unfortunately I think it'd also be a breaking change. People may be relying on it being smaller than the trigger width right now. They'd have to override it to get that behaviour back.

It would also add an extra resize observer to every popover which seems excessive.

Maybe it'd be useful to encapsulate the logic from there into a utility hook to help people manage the width. But I don't think we can default so it in every popover.

@rspbot
Copy link

rspbot commented Feb 7, 2025

@snowystinger
Copy link
Member Author

snowystinger commented Feb 7, 2025

I verified the fix not just with the test. I also ran verdaccio and added the stackblitz example to it

interface MyComboBoxProps<T extends object>
  extends Omit<ComboBoxProps<T>, 'children'> {
  label?: string;
  description?: string | null;
  errorMessage?: string | ((validation: ValidationResult) => string);
  children: React.ReactNode | ((item: T) => React.ReactNode);
}

function MyComboBox<T extends object>({
  label,
  description,
  errorMessage,
  children,
  ...props
}: MyComboBoxProps<T>) {
  const triggerRef = React.useRef<HTMLDivElement>(null);
  const [triggerWidth, setTriggerWidth] = useState<string | null>(null);

  const onResize = React.useCallback(() => {
    console.log(triggerRef.current);
    if (triggerRef.current) {
      setTriggerWidth(triggerRef.current.offsetWidth + 'px');
    }
  }, [triggerRef]);

  useLayoutEffect(() => {
    onResize();
  }, []);

  useResizeObserver({
    ref: triggerRef,
    onResize: onResize,
  });
  console.log(triggerWidth);

  return (
    <RACComboBox {...props}>
      <RACLabel>{label}</RACLabel>
      <div ref={triggerRef} className="my-combobox-container">
        <RACInput />
        <RACButton>▼</RACButton>
      </div>
      {description && <Text slot="description">{description}</Text>}
      <RACFieldError>{errorMessage}</RACFieldError>
      <RACPopover triggerRef={triggerRef}>
        <RACListBox>{children}</RACListBox>
      </RACPopover>
    </RACComboBox>
  );
}

function MyItem(props: ListBoxItemProps) {
  return (
    <RACListBoxItem
      {...props}
      className={({ isFocused, isSelected }) =>
        `my-item ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''}`
      }
    />
  );
}

@rspbot
Copy link

rspbot commented Feb 7, 2025

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.

[RAC] Refs in collection components point to their "hidden" node after SSR
3 participants