react-select icon indicating copy to clipboard operation
react-select copied to clipboard

Styles not Loading in Remix

Open justinhandley opened this issue 1 year ago • 12 comments

Hi There - I'm sorry, I'm not sure how to replicate this in your code sandbox. I have a suspicion that this somehow relates to Remix and SSR, but can't get to the root of it.

Basically, the form, doesn't display properly. If you change something in code, and dev mode reloads the page, then everything shows correctly. If you do an actual browser load or refresh, the form styles don't load correctly.

Screenshot 2024-07-22 at 12 42 13 PM

Based on my research, I have tried waiting for hydration, and I've tried using a useEffect to make sure it only loads in the client to separate from SSR, but neither works.

This is the controlled form field that I'm rendering:

import { useQuery } from '@apollo/client'
import { Control, Controller, FieldValues } from 'react-hook-form'
import AsyncSelect from 'react-select/async'
import { CSSObjectWithLabel, GroupBase, OptionsOrGroups } from 'react-select'

interface OptionType {
  label: string
  value: string
}

interface OptionItem {
  id: string
  name?: string
  firstName?: string
  lastName?: string
}

interface RelationSelectProps {
  field: {
    key: string
    options: {
      document?: any // Specify the GraphQL document type if available
      dataType?: string
      filter?: (items: OptionItem[]) => OptionItem[]
      selectOptionsFunction?: (items: OptionItem[]) => OptionType[]
      multi?: boolean
    }
  }
  control: Control<FieldValues, unknown>
}

function label(item: { id: string; name?: string; firstName?: string; lastName?: string }): string {
  if (item?.name) {
    return item.name
  }
  if (item?.firstName && item?.lastName) {
    return `${item.firstName} ${item.lastName}`
  }
  if (item?.firstName && !item?.lastName) {
    return item.firstName
  }
  return item.id
}

function defaultOptionsMap(items: OptionItem[]): OptionType[] {
  return items?.map?.((option) => ({ value: `${option.id}`, label: label(option) }))
}

export function RelationSelect({ field, control }: Readonly<RelationSelectProps>) {
  const { data, loading, refetch } = useQuery(field.options.document)

  let dataList: OptionItem[] =
    field.options.dataType && !loading ? data?.[field.options.dataType] : [{ value: '', label: 'Loading...' }]

  if (field.options.filter && !loading) {
    dataList = field.options.filter(dataList)
  }

  function getDefaultOptions(
    dataList: any[],
    options: { selectOptionsFunction?: (data: any[]) => OptionType[] },
  ): OptionType[] {
    if (dataList && dataList.length > 0) {
      if (options.selectOptionsFunction) {
        return options.selectOptionsFunction(dataList)
      } else {
        return defaultOptionsMap(dataList)
      }
    } else {
      return [{ value: '', label: 'No Matching Data Found' }]
    }
  }

  async function getStorageOptions(inputText: string): Promise<OptionsOrGroups<OptionType, GroupBase<OptionType>>> {
    const res = await refetch({ input: { search: inputText } })
    const data = field.options.dataType ? res.data[field.options.dataType] : null
    return getOptions(data)
  }

  function getOptions(data: any): OptionType[] | OptionsOrGroups<OptionType, GroupBase<OptionType>> {
    if (data) {
      if (field.options.selectOptionsFunction) {
        return field.options.selectOptionsFunction(data)
      } else {
        return defaultOptionsMap(data)
      }
    } else {
      return [{ value: '', label: 'No Matching Data Found' }]
    }
  }

  const customStyles = {
    control: (provided: CSSObjectWithLabel) => ({
      ...provided,
      backgroundColor: 'white',
      fontSize: '14px',
    }),
    singleValue: (provided: CSSObjectWithLabel) => ({
      ...provided,
      fontSize: '14px',
      color: '#64748b',
    }),
    option: (provided: CSSObjectWithLabel) => ({
      ...provided,
      fontSize: '14px',
      color: '#64748b',
    }),
  }

  return (
    <Controller
      control={control}
      name={field.key}
      render={({ field: { onChange, value } }) => (
        <AsyncSelect
          name={field.key}
          instanceId={field.key}
          value={value}
          key={field.key}
          defaultOptions={getDefaultOptions(dataList, field.options)}
          loadOptions={getStorageOptions}
          onChange={onChange}
          isLoading={loading}
          isMulti={field.options.multi}
          classNamePrefix="rs"
          styles={customStyles}
        />
      )}
    />
  )
}

justinhandley avatar Jul 22 '24 11:07 justinhandley

I'm in the same boat. There are a few similar issues open. I'm going to attempt the solution here in https://github.com/JedWatson/react-select/issues/3309#issuecomment-914446586 and see if by making each component with my own styles manually applied I can get past the issue.

Others:

https://github.com/JedWatson/react-select/issues/5710

https://github.com/JedWatson/react-select/issues/3680

TheAlexPorter avatar Jul 25 '24 21:07 TheAlexPorter

Update: I tried the method here where you add an emotion provider and that did the trick! Wrapping my app with a CacheProvider is all I had to do.

I don't like that I had to add an extra provider for styling I'm not using directly, but this beats the pain of having to migrate away from react-select.

Here is the snippet from my remix app:

// root.tsx

import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/react'

function AppWithProviders() {
	const data = useLoaderData<typeof loader>()
	const cache = createCache({
		key: 'test',
		prepend: false,
	})

	return (
		<HoneypotProvider {...data.honeyProps}>
			<CacheProvider value={cache}>
				<UserDataProvider
					data={{
						user: data.user,
						theme: data.requestInfo.userPrefs.theme,
					}}
				>
					<App />
				</UserDataProvider>
			</CacheProvider>
		</HoneypotProvider>
	)
}

From this Isssue: https://github.com/JedWatson/react-select/issues/3680#issuecomment-924248517

I hope this helps!

TheAlexPorter avatar Jul 25 '24 22:07 TheAlexPorter

Hey @TheAlexPorter - thanks so much for that - I did try wrapping the whole root of my app in an emotion provider and it didn't seem to have any effect. Is there anything else that you had to do to make this work? Do I have to consume the provider somewhere?

justinhandley avatar Jul 26 '24 01:07 justinhandley

I don't do anything other than wrap my app with the cache provider 🤔 The only other thing I did before attempting these solutions was upgrade react to 18.3.1 and remix-run to 2.10.3

TheAlexPorter avatar Jul 26 '24 13:07 TheAlexPorter

Same problem here with Astro JS (strangely, it's working well in my Remix projects), when using Astro with ViewTransitions component in the page layout headers, styles of react-select are broken when navigating to another page and going back to the page where react-select is called

grohart avatar Aug 06 '24 14:08 grohart

That's the problem with those react projects relying on overly complex dependencies like emotion for doing super simple things like basic styling, it gets really unstable over time

I have a hard time to see how you even need this emotion dependency here, when you have perfectly fine web standards for defining styles that does not requires overly complex dependencies that breaks all over the place + you have JSX to make it conditional

Behavior is not even consistent... In our case on remix it works sometimes, sometimes it does not, depending on the browser and how you access the page (direct reload vs coming from another page).

Always try to keep it simple and low in dependencies!

And from the number of issues + PR this project does not really seems really maintained (which is understandable). And regarding number of issues + PRs + frequencies or recent commits emotion is also not maintained anymore. So this project is now condemned to not work in the future due to its heavy dependencies

Now I guess we just need to rewrite this select component directly. Good thing it's quite basic, it will not take much time but yeah, remember to always try to use web standards instead of overly complex dependencies for simple things like applying styles...

vemonet avatar Oct 24 '24 08:10 vemonet

On my end, React Select can work / not work depending on:

  1. Browser extension: In dev, on Firefox, having Bitwarden extension makes the problem appear. Disabling Bitwarden fixes the problem.
  2. In production, even with Bitwarden disabled, the problem is there.

This repository shows a minimal setup where I only created a new Remix app and installed React Select, and you can see it failing in Firefox with the Bitwarden extension.

wowawiwa avatar Oct 24 '24 16:10 wowawiwa

I got it clearer:

  • The problem actually happens when re-rendering of the <head> tag (see demo in the repo)
  • It just so happens that the Bitwarden causes an hydratation error Uncaught Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering which causes the re-rendering of the head tag that I mention above.

wowawiwa avatar Oct 25 '24 15:10 wowawiwa

Same problem here with Astro JS (strangely, it's working well in my Remix projects), when using Astro with ViewTransitions component in the page layout headers, styles of react-select are broken when navigating to another page and going back to the page where react-select is called

Did you find a solution to this? I'm having the same problem with Astro.

i-bsd avatar Dec 11 '24 00:12 i-bsd

Did you find a solution to this? I'm having the same problem with Astro.

Unfortunately no, I've given up react-select and I use the package tom-select instead which has the same functionalities as those I was looking for in react-select.

Anyway, I'm gradually abandoning React in favor of Svelte. I know this isn't really the place, feel free to delete my post in that case, but if anyone's interested, here is a Svelte component I've written to use tom-select (up to you to improve or adapt this code to React):

<script lang="ts">
  import { onMount } from "svelte";
  import TomSelect from 'tom-select';
  import "tom-select/dist/css/tom-select.default.css";
  let {
    options = {},
    name = '',
    selectedValues = [],
    placeholder = "Select...",
    noResultMessage = "No result"
  } = $props<{
    options: any;
    name: string;
    selectedValues: any;
    placeholder: string;
    noResultMessage: string;
  }>();

  let selectElement: HTMLSelectElement;
  let isHydrated = $state<boolean>( false );
  let tom = $state<TomSelect>();

  onMount( () => {
    tom = new TomSelect( selectElement, {
      items: selectedValues,
      create: false,
      placeholder: `▼ ${ placeholder }`,
      hidePlaceholder: true,
      hideSelected: false,
      maxOptions: undefined,
      plugins: {
        remove_button:{
          title: 'Remove',
        },
        clear_button:{
          title:'Remove all selected options',
          html: ( data: any ) => `
            <button class="${ data.className }" title="${ data.title }">
              <em class="fa fa-fw fa-delete-left"></em>
            </button>
          `
        },
        caret_position: true,
        input_autogrow: true
      },
      onClear: () => {
        tom?.close();
        selectedValues = [];
      },
      onInitialize: () => isHydrated = true,
      onItemAdd: ( value: string ) => selectedValues.push( value ),
      onItemRemove: ( value: string | number ) => selectedValues = selectedValues.filter( ( v: string | number ) => {
        return v.toString() !== value.toString();
      } ), 
      render: {
        option: ( data: any, escape: any ) => {
          const isSelected = selectedValues.includes( data.value );
          return `
            <div class="tom-option ${ isSelected ? 'selected' : '' }">
              ${ escape( data.text ) }
            </div>
          `;
        },
        no_results: () => `
          <div class="no-results">
            ${ noResultMessage }
          </div>
        `,
        item: ( data: any, escape: any ) => `
          <div class="tom-item">
            ${ escape( data.text ) }
          </div>
        `
      }
    } );
  } );
</script>

<div class:not-hydrated={ !isHydrated }>
  <select
    bind:this={ selectElement }
    name={ !isHydrated ? name : undefined }
    size={ 1 }
    multiple
    class="h-16"
  >
    {#if !isHydrated}
    <option
      value=""
      class="h-full"
    >{@html placeholder}</option>
    {/if}
    {#each options as option}
    <option
      value={ option.value }
      selected={ !isHydrated && selectedValues.includes( option.value ) }
    >
      {@html option.label}
    </option>
    {/each}
  </select>
</div>
{#each selectedValues as item, index}
<input
  type="hidden"
  name={ `${ name }[${ index }]` }
  value={ item }
/>
{/each}

<style lang="postcss">
  :global(.tom-option.selected) {
    @apply bg-blue-500 text-slate-100;
  }
  :global(.tom-item) {
    @apply h-1/2;
  }
  :global(.ts-control) {
    @apply overflow-y-auto;
    :global(input) {
      @apply placeholder:text-xs placeholder:text-gray-500;
    }
  }
 .not-hydrated select {
    @apply input input-bordered w-full;
  }
</style>
<Select
  client:load
  options={ options_object }
  selectedValues={ values_object }
  name={ key_string }
  placeholder={ placeholder_string }
  noResultMessage={ message_string }
/>

grohart avatar Dec 12 '24 09:12 grohart

Did you find a solution to this? I'm having the same problem with Astro.

Unfortunately no, I've given up react-select and I use the package tom-select instead which has the same functionalities as those I was looking for in react-select.

Anyway, I'm gradually abandoning React in favor of Svelte. I know this isn't really the place, feel free to delete my post in that case, but if anyone's interested, here is a Svelte component I've written to use tom-select (up to you to improve or adapt this code to React):

I just spent all day switching out my react-select components to Svelte (using svelte-select). It's excellent, but not yet up-to-date with Svelte 5 so not sure about its longevity.

Hopefully someone finds a fix for react-select and Astro. Haven't heard of tom-select either. I'll take a look.

i-bsd avatar Dec 12 '24 10:12 i-bsd

Hopefully someone finds a fix for react-select and Astro.

The fix is really simple: stop using broken https://emotion.sh... Why would you need a whole library just for interpolating CSS strings using JS? When building reusable components authors should always strive to reduce at maximum the use of external libraries, especially when they are bringing so little to the table

Remix mentions on their website that SSR is broken by css-in-js: https://remix.run/docs/en/main/styling/css-in-js

Most CSS-in-JS approaches aren't recommended for use in Remix because they require your app to render completely before you know what the styles are. This is a performance issue and prevents streaming features like defer.

Emotion has a long documentation page with tons of crazy stuffs to do just to make their ~string interpolation~ "css-in-js" works on the server https://emotion.sh/docs/ssr#api

So if anyone wants to fix the react-select component here are all the required info

vemonet avatar Jan 15 '25 11:01 vemonet