import { isServer, isWeb } from '@tamagui/constants' import { useRef, useSyncExternalStore } from 'react' import { getSetting } from '../config' import { resetMediaStyleCache } from '../helpers/createMediaStyle' import { matchMedia } from '../helpers/matchMedia' import { mediaObjectToString } from '../helpers/mediaObjectToString' import { getMedia, mediaKeys, mediaQueryConfig, setMediaState, } from '../helpers/mediaState' import type { ComponentContextI, DebugProp, GetStyleState, IsMediaType, MediaQueryState, TamaguiInternalConfig, UseMediaState, WidthHeight, } from '../types' import { defaultMediaImportance } from '../helpers/pseudoDescriptors' const mediaKeyRegex = /\$(platform|theme|group)-/ export const isMediaKey = (key: string): boolean => { if (key[0] !== '$') return false if (mediaKeys.has(key)) return true if (mediaKeyRegex.test(key)) return true return false } export const getMediaKey = (key: string): IsMediaType => { if (key[0] !== '$') return false if (mediaKeys.has(key)) return true const match = key.match(mediaKeyRegex) if (match) return match[1] as 'platform' | 'theme' | 'group' return false } // for SSR capture it at time of startup let initState: MediaQueryState let mediaKeysOrdered: string[] export const getMediaKeyImportance = (key: string) => { if (process.env.NODE_ENV === 'development' && key[0] === '$') { throw new Error('use short key') } // + 100 because we set base usedKeys=1, pseudos are 2-N (however many we have) // all media go above all pseudos so we need to pad it based on that // right now theres 5 pseudos but in the future could be a few more return mediaKeysOrdered.indexOf(key) + 100 } const dispose = new Set() let mediaVersion = 0 export const configureMedia = (config: TamaguiInternalConfig) => { const { media } = config const mediaQueryDefaultActive = getSetting('mediaQueryDefaultActive') if (!media) return mediaVersion++ // reset cached media style prefixes/selectors so they get recalculated with new key order resetMediaStyleCache() for (const key in media) { getMedia()[key] = mediaQueryDefaultActive?.[key] || false mediaKeys.add(`$${key}`) } Object.assign(mediaQueryConfig, media) initState = { ...getMedia() } mediaKeysOrdered = Object.keys(media) setupMediaListeners() } function unlisten() { dispose.forEach((cb) => cb()) dispose.clear() } /** * Note: This should *not* set the state on the first render! * Because to avoid hydration issues SSR must match the server * *and then* re-render with the actual media query state. */ let setupVersion = -1 export function setupMediaListeners() { if (isWeb && isServer) return if (process.env.IS_STATIC) return // avoid setting up more than once per config if (setupVersion === mediaVersion) return setupVersion = mediaVersion // hmr, undo existing before re-binding unlisten() for (const key in mediaQueryConfig) { const str = mediaObjectToString(mediaQueryConfig[key]) const getMatch = () => matchMedia(str) const match = getMatch() if (!match) { throw new Error('⚠️ No match') } // react native needs these deprecated apis for now match.addListener(update) dispose.add(() => { match.removeListener(update) }) function update() { const next = !!getMatch().matches if (next === getMedia()[key]) return setMediaState({ ...getMedia(), [key]: next }) updateMediaListeners() } update() } } const listeners = new Set() export function updateMediaListeners() { listeners.forEach((cb) => cb(getMedia())) } type MediaState = { enabled?: boolean keys?: Set | null } const States = new WeakMap() export function setMediaShouldUpdate( ref: any, enabled?: boolean, keys?: MediaState['keys'] ) { const cur = States.get(ref) if (!cur || cur.enabled !== enabled || keys) { States.set(ref, { ...cur, enabled, keys, }) } } function subscribe(subscriber: () => void) { listeners.add(subscriber) return () => { listeners.delete(subscriber) } } export function useMedia( componentContext?: ComponentContextI, debug?: DebugProp ): UseMediaState { 'use no memo' const componentState = componentContext ? States.get(componentContext) : null const internalRef = useRef<{ keys: Set lastState: MediaQueryState pendingState?: MediaQueryState }>(null) if (!internalRef.current) { internalRef.current = { keys: new Set(), lastState: getMedia(), } } // reset on next render if (internalRef.current.pendingState) { internalRef.current.lastState = internalRef.current.pendingState internalRef.current.pendingState = undefined } const { keys } = internalRef.current // clear each render to track only rendered touched keys if (keys.size) { keys.clear() } const state = useSyncExternalStore( subscribe, () => { const curKeys = componentState?.keys || keys const { lastState, pendingState } = internalRef.current! if (!curKeys.size) { return lastState } const ms = getMedia() for (const key of curKeys) { if (ms[key] !== (pendingState || lastState)[key]) { if (process.env.NODE_ENV === 'development' && debug) { console.warn(`useMedia() ✍️`, key, lastState[key], '=>', ms[key]) } // in emitter mode (no-rerender) avoid changing state, instead emit if (componentContext?.mediaEmit) { componentContext.mediaEmit(ms) internalRef.current!.pendingState = ms return lastState } internalRef.current!.lastState = ms return ms } } return lastState }, getServerSnapshot ) return new Proxy(state, { get(_, key) { if (!disableMediaTouch && typeof key === 'string') { keys.add(key) } return Reflect.get(state, key) }, }) } const getServerSnapshot = () => initState let disableMediaTouch = false export function _disableMediaTouch(val: boolean) { disableMediaTouch = val } export function getMediaState(mediaGroups: Set, layout: WidthHeight) { disableMediaTouch = true let res: Record try { res = Object.fromEntries( [...mediaGroups].map((mediaKey) => { return [mediaKey, mediaKeyMatch(mediaKey, layout as any)] }) ) } finally { disableMediaTouch = false } return res } export const getMediaImportanceIfMoreImportant = ( mediaKey: string, key: string, styleState: GetStyleState, isSizeMedia: boolean ) => { const importance = isSizeMedia ? getMediaKeyImportance(mediaKey) : defaultMediaImportance const usedKeys = styleState.usedKeys return !usedKeys[key] || importance > usedKeys[key] ? importance : null } const cachedMediaKeyToQuery: Record = {} export function mediaKeyToQuery(key: string) { return ( cachedMediaKeyToQuery[key] || (cachedMediaKeyToQuery[key] = mediaObjectToString(mediaQueryConfig[key])) ) } export function mediaKeyMatch( key: string, dimensions: { width: number; height: number } ) { const mediaQueries = mediaQueryConfig[key] const result = Object.keys(mediaQueries).every((query) => { const expectedVal = +mediaQueries[query] const isMax = query.startsWith('max') const isWidth = query.endsWith('Width') const givenVal = dimensions[isWidth ? 'width' : 'height'] // if not max then min return isMax ? givenVal < expectedVal : givenVal > expectedVal }) return result }