Skip to content

Instantly share code, notes, and snippets.

@EyMaddis
Created February 20, 2020 12:57
Show Gist options
  • Save EyMaddis/35ae3b269e4658527a1f8e374bd434ac to your computer and use it in GitHub Desktop.
Save EyMaddis/35ae3b269e4658527a1f8e374bd434ac to your computer and use it in GitHub Desktop.
React Native for Web SSR Media Queries
export type DeviceSize = 'mobile' | 'small' | 'medium' | 'large'
export const deviceSize: { [key in DeviceSize]: [number, number] } = {
mobile: [0, 575],
small: [576, 767],
medium: [768, 991],
large: [992, 999999999],
}
export interface MediaQueryObject {
minWidth?: number | DeviceSize
maxWidth?: number | DeviceSize
}
function build(
s: number | void | DeviceSize,
property: 'min-width' | 'max-width',
deviceIndex: 0 | 1
) {
let size
if (typeof s === 'number') {
size = s
} else if (typeof s === 'string') {
size = deviceSize[s][deviceIndex]
}
if (process.env.NODE_ENV === 'development') {
if (property === 'min-width' && size === 0) {
throw new Error(
`min-width of 0 does not make sense, change the value from "${s}" to something larger or use max-width`
)
}
if (property === 'max-width' && s === 'large') {
throw new Error(
`max-width of "large" does not make sense as it should not be bound`
)
}
}
if (typeof size === 'number') {
return `(${property}: ${size}px)`
} else {
return null
}
}
export function mediaQueryToString({ minWidth, maxWidth }: MediaQueryObject) {
const query = [
build(minWidth, 'min-width', 0),
build(maxWidth, 'max-width', 1),
]
.filter(el => !!el)
.join(' and ')
return query
}
import { useEffect, useRef } from 'react'
export function useFirstRender() {
const ref = useRef(true)
useEffect(() => {
requestAnimationFrame(() => {
ref.current = false
})
}, [])
return ref
}
import debug from 'debug'
import { CSSProperties, useEffect, useState } from 'react'
import { StyleProp } from 'react-native'
import { MediaQueryObject } from './mediaQueries/toString'
import { useClientOnlyMediaQuery } from './useClientOnlyMediaQuery'
import { useFirstRender } from './useFirstRender'
const log = debug('app:hooks:useMediaQueryStyle')
export interface StyleMap {
query: MediaQueryObject
style: CSSProperties
}
export class MediaQueryStyle {
constructor(public identifier: string, private styleMap: StyleMap[]) {}
compile() {
log('compiling', this.identifier, this.styleMap)
return this.styleMap.map(({ query, style }) => {
return function useStyle() {
const matches = useClientOnlyMediaQuery(query)
return matches ? style : null
}
})
}
}
export function createMediaQueryStyle(
identifier: string,
styleMap: StyleMap[]
) {
return new MediaQueryStyle(identifier, styleMap)
}
export function useMediaQueryStyle(
style: MediaQueryStyle
): [string, StyleProp<{}>] {
const [hooks] = useState(() => {
// this is defined to only run once
return style.compile()
})
const styles = []
for (const useStyle of hooks) {
// The hooks array will never change! This is the only reason we can do this inside a loop!
// tslint:disable-next-line:react-hooks-nesting
const s = useStyle()
styles.push(s)
}
const firstRenderRef = useFirstRender()
useEffect(() => {
if (!firstRenderRef.current && process.env.NODE_ENV !== 'production') {
throw new Error('useMediaQueryStyle should not be updated')
}
}, [style])
const { identifier } = style
return [identifier, styles]
}
import debug from 'debug'
// @ts-ignore
// tslint:disable-next-line:no-implicit-dependencies
import hyphenateStyleName from 'hyphenate-style-name' // from react-native-web
import { useEffect, useState } from 'react'
import { StyleProp, StyleSheet } from 'react-native'
// @ts-ignore
import createReactDOMStyle from 'react-native-web/dist/exports/StyleSheet/createReactDOMStyle'
// @ts-ignore
import prefixStyles from 'react-native-web/dist/modules/prefixStyles'
import { hasRule, setRule } from '../lib/CSSInjection'
import { MediaQueryObject, mediaQueryToString } from './mediaQueries/toString'
import { useFirstRender } from './useFirstRender'
const log = debug('app:hooks:useMediaQueryStyle')
// better than React.CSSProperties as we support e.g. paddingHorizontal
type RNStyles = StyleSheet.NamedStyles<any>['does not matter']
interface StyleMap {
query: MediaQueryObject
style: RNStyles
}
type Value = object | any[] | string | number
interface Style {
[key: string]: Value
}
// copied from react-native-web
function createDeclarationBlock(style: Style) {
const domStyle = prefixStyles(createReactDOMStyle(style))
const declarationsString = Object.keys(domStyle)
.map(property => {
const value = domStyle[property]
const prop = hyphenateStyleName(property)
// The prefixer may return an array of values:
// { display: [ '-webkit-flex', 'flex' ] }
// to represent "fallback" declarations
// { display: -webkit-flex; display: flex; }
if (Array.isArray(value)) {
return value.map(v => `${prop}:${v}`).join(';')
} else {
return `${prop}:${value}`
}
})
// Once properties are hyphenated, this will put the vendor
// prefixed and short-form properties first in the list.
.sort()
.join(';')
return `{${declarationsString};}`
}
class MediaQueryStyle {
private compiled = false
constructor(public identifier: string, private styleMap: StyleMap[]) {}
// we want lazy intialization
compile() {
// component could have been rendered once
const { identifier } = this
if (this.compiled) {
return
}
if (hasRule(identifier)) {
return // most likely from the server
}
this.compiled = true
this.styleMap.map(({ query, style }) => {
const css = createDeclarationBlock(
// @ts-ignore
style
)
const mediaQuery = mediaQueryToString(query)
const str = `@media ${mediaQuery} {[data-media~="${identifier}"] ${css}}`
setRule(identifier, str)
log('adding CSS rule to DOM', identifier, str)
})
}
}
export function createMediaQueryStyle(
identifier: string,
styleMap: StyleMap[]
) {
return new MediaQueryStyle(identifier, styleMap)
}
export function useMediaQueryStyle(
style: MediaQueryStyle
): [string, StyleProp<{}>] {
const [dummyStyleObject] = useState(() => {
// use state is guaranteed to only be called once, useMemo isn't.
// useEffect would be too late, we want this to happen during render to avoid style flashing
style.compile() // TODO: maybe we need to batch this in order to avoid style thrashing...?
return {}
})
const firstRenderRef = useFirstRender()
useEffect(() => {
if (!firstRenderRef.current && process.env.NODE_ENV !== 'production') {
throw new Error('useMediaQueryStyle should not be updated')
}
}, [style])
const { identifier } = style
return [identifier, dummyStyleObject]
}
import debug from 'debug'
const log = debug('app:lib:CSSInjection')
const rules: { [key in string]: string } = {}
const STATE_ELEMENT = 'CSSInjection'
const isBrowser = process.browser
if (isBrowser) {
const el = document.getElementById(STATE_ELEMENT)
if (el) {
const usedIds = (el.textContent || '').split(',')
log('skipping SSR rules', usedIds)
usedIds.forEach(id => {
rules[id] = 'SERVER' // do not register rules that are provided by the server
})
}
}
let styleSheet: StyleSheet | null
if (isBrowser) {
styleSheet = (() => {
// Create the <style> tag
const style = document.createElement('style')
style.id = 'CSSInjection'
// WebKit hack :(
style.appendChild(document.createTextNode(''))
// Add the <style> element to the page
document.head.appendChild(style)
return style.sheet
})()
}
export function setRule(id: string, text: string) {
if (!hasRule(id)) {
log('adding rule', id, text)
// do not register rules that are provided by the server
rules[id] = text
if (styleSheet) {
// @ts-ignore
styleSheet.insertRule(text)
}
}
}
export function hasRule(id: string) {
return !!rules[id]
}
export function removeRule(id: string) {
delete rules[id]
}
export function flush() {
const keys = Object.keys(rules)
return {
stateHTML: { id: STATE_ELEMENT, content: keys.join(',') },
css: keys.map(key => rules[key]).join('\n'),
}
}
import { flush as flushCustomCSS } from '../src/lib/CSSInjection'
...
export default class MyDocument extends Document {
public static async getInitialProps({
renderPage,
}: {
renderPage: () => any
}) {
AppRegistry.registerComponent('Main', () => Main)
const { getStyleElement } = AppRegistry.getApplication('Main')
const page = renderPage()
const customCSSMediaQueries = flushCustomCSS() // <-- ADD THIS
const styles = [
<style
key="1"
dangerouslySetInnerHTML={{ __html: normalizeNextElements }}
/>,
getStyleElement(),
...styledJSX(),
// ADD THESE TOO:
// must be at the end in order to have higher CSS priority
<style
key="mediaQuery"
dangerouslySetInnerHTML={{ __html: customCSSMediaQueries.css }}
/>,
<script
key="mediaQueryState"
type="text/plain"
id={customCSSMediaQueries.stateHTML.id}
dangerouslySetInnerHTML={{
__html: customCSSMediaQueries.stateHTML.content,
}}
/>,
]
return { ...page, styles: React.Children.toArray(styles) }
}
import React, { ReactNode } from 'react'
import { StyleProp, StyleSheet, View } from 'react-native'
import {
createMediaQueryStyle,
useMediaQueryStyle,
} from '../../hooks/useMediaQueryStyle'
import { Theme } from '../../lib/themes/Theme'
/**
* First you create MediaQueryStyleSheets with createMediaQueryStyle(<A unique identifier>, Styles[])
* Then you use the useMediaQueryStyle() hook which returns an CSS identifier for web (usind data-media="..." - there is no longer className support :( )
* The seconds return value from the hook is used for native applications.
**/
const Styles = StyleSheet.create({
paddingHorizontal: {
paddingHorizontal: Theme.spacing.medium,
},
paddingVertical: {
paddingVertical: Theme.spacing.medium,
},
})
const MediaQueryStyleVertical = createMediaQueryStyle('PagePadding-v', [
{
query: {
maxWidth: 'mobile' as const,
},
style: {
paddingVertical: Theme.spacing.small,
},
},
])
const MediaQueryStyleHorizontal = createMediaQueryStyle('PagePadding-h', [
{
query: {
maxWidth: 'mobile' as const,
},
style: {
paddingHorizontal: Theme.spacing.small,
},
},
])
interface Props {
children: ReactNode
style?: StyleProp<any>
'data-media'?: string
vertical?: boolean
horizontal?: boolean
}
export function PagePadding({
children,
style,
horizontal = true,
vertical = true,
'data-media': dataMedia,
}: Props) {
const [idVertical, sizedStyleVertical] = useMediaQueryStyle(
MediaQueryStyleVertical
)
const [idHorizontal, sizedStyleHorizontal] = useMediaQueryStyle(
MediaQueryStyleHorizontal
)
return (
<View
data-media={
idVertical + ' ' + idHorizontal + (dataMedia ? ' ' + dataMedia : '')
}
style={[
...[horizontal ? [Styles.paddingHorizontal, sizedStyleHorizontal] : []],
...(vertical ? [Styles.paddingVertical, sizedStyleVertical] : []),
style,
]}
>
{children}
</View>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment