Created
February 20, 2020 12:57
-
-
Save EyMaddis/35ae3b269e4658527a1f8e374bd434ac to your computer and use it in GitHub Desktop.
React Native for Web SSR Media Queries
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useRef } from 'react' | |
export function useFirstRender() { | |
const ref = useRef(true) | |
useEffect(() => { | |
requestAnimationFrame(() => { | |
ref.current = false | |
}) | |
}, []) | |
return ref | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'), | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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