dripsy: Recursively creating themed component doesn't work

What

There’s a bug with createThemedComponent (and thus styled), where passing a component made by dripsy to it doesn’t work.

import { styled, View } from 'dripsy'
import * as Native from 'react-native'

const Container = styled(View) // 🚨error
const Wrapper = styled(Native.View) // 👍 works

Same goes for createThemedComponent:

import { createThemedComponent, View } from 'dripsy'
import * as Native from 'react-native'

const Container = createThemedComponent(View) // 🚨error
const Wrapper = createThemedComponent(Native.View) // 👍 works

Why

First, this bug needs to be fixed for the custom pragma from #31 to get merged.

Second, I’ve really enjoyed using the styled function from the fresnel-2 branch in my real-world app. styled is a wrapper around createThemedComponent with a cooler name and syntax.

While using the sx prop is the central API, I would like to treat styled syntax as a first-class citizen, too.

import { styled } from 'dripsy'

const Container = styled(View)({
  maxWidth: 10
})

I’ve enjoyed the API of naming UI elements outside of the component scope. It prevents cluttering the return statement with styles.

With styled-components, this was pretty annoying, because I had to pass a function to my styles, redeclare prop types, and it was just a mess. But with dripsy, it’s easy; I can still pass an sx prop to the component returned by styled if it needs render-specific variables in its styles, like so:

const Container = styled(View)({
  maxWidth: 10
})

const Box = ({ height }) => <Container sx={{ height }} />

How

I’m not fully sure why this bug is exists, but I could see issues with recursively wrapping components in HOCs.

I think an easy solution would be to add something like a static isDripsyComponent field to any component returned by createThemedComponent. Then, in createThemedComponent, we check if it already is a dripsy component. If it is, we just return the component itself and forward the props down – nothing special.

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 17 (17 by maintainers)

Most upvoted comments

For whatever reason, I cannot get the TS suggestions for SxProps['sx'] type to work when it’s a return type of a function.

// types.ts
export type ThemedOptions<T> = {
  defaultStyle?:
    | SxProps['sx'] // this works fine with intellisense 
    | ((props: T) => SxProps['sx']) // no intellisense here...
  themeKey?: string
  defaultVariant?: string
}

I’ve also tried using SxStyleProp and ThemeUIObject, but no luck yet. When I use my own custom type, it works fine.

Here’s the updated createThemedComponent file:

/* eslint-disable @typescript-eslint/ban-ts-ignore */
import React, { ComponentType, ComponentProps, useMemo } from 'react'
import { ThemedOptions, StyledProps } from './types'
import { useThemeUI } from '@theme-ui/core'
import { useBreakpointIndex, mapPropsToStyledComponent } from '.'
import { SSRComponent } from './ssr-component'
import { Platform } from 'react-native'

type Props<P> = Omit<StyledProps<P>, 'theme' | 'breakpoint'>

export function createThemedComponent<P, T>(
  Component: ComponentType<P>,
  { defaultStyle: baseStyle, ...options }: ThemedOptions<T> = {}
) {
  // without styled-components...
  const WrappedComponent = React.forwardRef<
    typeof Component,
    Props<P> & ComponentProps<typeof Component> & T
  >(function Wrapped(prop, ref) {
    const {
      sx,
      as: SuperComponent,
      variant,
      style,
      webContainerSx,
      themeKey = options.themeKey,
      ...props
    } = prop
    const defaultStyle =
      typeof baseStyle === 'function' ? baseStyle(prop) : baseStyle

    const { theme } = useThemeUI()
    const breakpoint = useBreakpointIndex({
      __shouldDisableListenerOnWeb: true,
    })
    // const ssr = useIsSSR()
    // change/remove this later maybe
    const ssr = Platform.OS === 'web'

    const { responsiveSSRStyles, ...styles } = useMemo(
      () =>
        mapPropsToStyledComponent(
          {
            theme,
            breakpoint: Platform.OS === 'web' && ssr ? undefined : breakpoint,
            variant,
            sx,
            style,
          },
          {
            ...options,
            themeKey,
            defaultStyle,
          }
        )(),
      [breakpoint, defaultStyle, ssr, style, sx, theme, themeKey, variant]
    )

    const TheComponent = SuperComponent || Component

    if (Platform.OS === 'web' && ssr && !!responsiveSSRStyles?.length) {
      return (
        <SSRComponent
          {...props}
          Component={TheComponent as React.ComponentType<unknown>}
          responsiveStyles={responsiveSSRStyles}
          style={styles}
          ref={ref}
          containerStyles={
            webContainerSx as ComponentProps<
              typeof SSRComponent
            >['containerStyles']
          }
        />
      )
    }

    return (
      <TheComponent {...((props as unknown) as P)} ref={ref} style={styles} />
    )
  })

  WrappedComponent.displayName = `Themed.${Component.displayName ??
    'NoNameComponent'}`

  return React.memo(WrappedComponent)
}

Once I get this fixed I’ll clean up the code and remove old stuff that went unused. The naming is a bit confusing in a number of places, too.

@cmaycumber I haven’t updated to put your explicit return type changes yet, I can add that once I figure this out.

Got it, I’ll try this out. ComponentType probably makes more sense. I see that @expo/html-elements is using React.PropsWithChildren, I haven’t used that type before.

I’m also working on figuring out a way to get React.memo to work without using @ts-ignore, such that it forwards the correct details. Something like this might work to forward the props generic:

const typedMemo: <T>(c: T) => T = React.memo
return typedMemo(WrappedComponent)

But I’m not sure if it works with forwarding refs too yet.

Another temporary workaround building off my previous one that fixes @expo/html-elements would add an additional optional child prop:

export function createThemedComponent<P>(
	Component: ComponentType<P>,
	options: ThemedOptions = {}
): React.FC<P & Props<P> & { children?: React.ReactNode }> {

...

// @ts-ignore
return React.memo(WrappedComponent)

Another thing to note React.FC can be replaced with ComponentType which may be more accurate because it shows a React.ForwardRefExoticComponent as the return type.

Okay I think it actually is just a TS error for createThemedComponent, and that it’s actually breaking for styled. I need to keep testing to be sure. If it is, @cmaycumber’s work-around should do it:

export function createThemedComponent<P>(
	Component: ComponentType<P>,
	options: ThemedOptions = {}
): React.FC<P & Props<P>> {

...

// @ts-ignore
return React.memo(WrappedComponent)

In any case, I should probably re-write styled, either to 1) match createThemedComponent from scratch, or 2) to simply return createThemedComponent. Option 1 would allow us to pass a pure function to determine styles, option 2 would only allow a plain sx object.

The simplest rewrite would look like this:

export function styled<P>(
  Component: ComponentType<P>,
  {
    themeKey,
    defaultVariant,
  }: { themeKey?: string; defaultVariant?: string } = {}
) {
  return (sx: Required<SxProps>['sx']) => {
    return createThemedComponent(Component, {
      defaultStyle: sx,
      themeKey,
      defaultVariant,
    })
  }
}

Which would allow this usage:

import { View, styled } from 'dripsy'

// plain objects only
const Box = styled(View)({
  pb: 2
})

<Box sx={{ pb: 3 }} />

…but it would not allow this usage:

import { View, styled } from 'dripsy'

// pure function to determine styles
const Box = styled<{ pb: number }>(View)(props => ({
  pb: props.pb
}))

<Box pb={3} />

The passing around of props and refs gets pretty messy in React with this many HOCs. It seems like the current (and admittedly messy) implementation of styled is breaking somewhere along the way of recursively forwarding props and refs (or so I would guess).

I’ll test that out.

It actually is more than a TS error, for some reason the actual renders don’t work properly.

export function createThemedComponent<P>(
	Component: ComponentType<P>,
	options: ThemedOptions = {}
): React.FC<P & Props<P>> {

...

// @ts-ignore
return React.memo(WrappedComponent)

This might be a decent workaround if the types are the only issue.

I’ve tried to make some work-arounds but still haven’t been able to debug this.