Skip to content

Commit

Permalink
✨ Create isolated context and components
Browse files Browse the repository at this point in the history
  • Loading branch information
exah committed Dec 25, 2020
1 parent af4a7b3 commit 645ba25
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 73 deletions.
48 changes: 45 additions & 3 deletions src/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import '@testing-library/jest-dom/extend-expect'
import { screen, render } from '@testing-library/react'
import { createElement as h, useState } from 'react'
import { hydrate } from 'react-dom'
import { hydrate, unmountComponentAtNode } from 'react-dom'
import { renderToString } from 'react-dom/server'
import { Fill, Slot, SlotsProvider } from '.'
import createSlots, { Fill, Slot, SlotsProvider } from '.'

let isServer = false
jest.mock('./is-server', () => () => isServer)
Expand Down Expand Up @@ -118,7 +118,7 @@ test('render nested node in multiple places', () => {
defaultTest()
})

test('should hydrate without errors on server render', async () => {
test('hydrate without errors on server render', async () => {
isServer = true

const root = document.createElement('div')
Expand All @@ -139,4 +139,46 @@ test('should hydrate without errors on server render', async () => {
expect(error).not.toHaveBeenCalled()

defaultTest()

unmountComponentAtNode(root)
})

test('create isolated context with specified types', async () => {
const NS = createSlots<'foo' | 'bar'>()

const List = ({ children }: { children: React.ReactNode }) => (
<ul>
<li>
<NS.Slot name="foo" />
</li>
<li>
<NS.Slot name="bar" />
</li>
<li>
<NS.Slot
// This name not specified in types when `createSlots` was called
// @ts-expect-error
name="baz"
>
Baz Fallback
</NS.Slot>
</li>
{children}
</ul>
)

render(
<NS.Provider>
<List>
<NS.Fill name="foo">Foo</NS.Fill>
<NS.Fill name="bar">Bar</NS.Fill>
</List>
</NS.Provider>
)

const items = screen.getAllByRole('listitem')

expect(items[0]).toHaveTextContent('Foo')
expect(items[1]).toHaveTextContent('Bar')
expect(items[2]).toHaveTextContent('Baz Fallback')
})
150 changes: 80 additions & 70 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,103 +11,113 @@ import {

import isServer from './is-server'

type Unsubscribe = () => void
type Callback = (node: React.ReactNode) => void
type Events = { [K in string | symbol]: Callback }
export interface ProviderProps {
children: React.ReactNode
}

interface Emitter<E extends Events> {
emit<K extends keyof E>(event: K, node: React.ReactNode): void
on<K extends keyof E>(event: K, cb: E[K]): Unsubscribe
export interface SlotProps<Names extends PropertyKey = PropertyKey> {
name: Names
children?: React.ReactNode
}

export interface FillProps<Names extends PropertyKey = PropertyKey>
extends SlotProps<Names> {}

const useUniversalEffect = (
effect: React.EffectCallback,
deps: React.DependencyList
) => {
if (isServer()) {
const cleanup = effect()
if (cleanup) cleanup()
return
} else {
useLayoutEffect(effect, deps)
}

return useLayoutEffect(effect, deps)
}

const SlotsContext = createContext<Emitter<Events> | null>(null)

export interface SlotsProviderProps {
children: React.ReactNode
}

export function SlotsProvider(props: SlotsProviderProps) {
const emitter = useMemo(function <E extends Events>(): Emitter<E> {
const events: { [K in keyof E]?: E[K][] } = {}
return {
emit(event, node) {
const source = events[event]
if (source) source.forEach((cb) => cb(node))
},
on(event, cb) {
const source = (events[event] = events[event] || [])!
source.push(cb)
return () => {
events[event] = source.filter((item) => item !== cb)
}
},
}
}, [])
export const { Provider: SlotsProvider, Slot, Fill } = create<PropertyKey>()

return (
<SlotsContext.Provider value={emitter}>
{props.children}
</SlotsContext.Provider>
)
}
export default function create<Names extends PropertyKey>() {
type Unsubscribe = () => void
type Callback = (node: React.ReactNode) => void
type Events = { [K in Names]: Callback[] }

function useSlotsContext() {
const context = useContext(SlotsContext)
interface Emitter {
emit<K extends Names>(event: K, node: React.ReactNode): void
on<K extends Names>(event: K, cb: Callback): Unsubscribe
}

if (context === null) {
throw new Error('Must be used inside `SlotsProvider`')
const SlotsContext = createContext<Emitter | null>(null)

function Provider(props: ProviderProps) {
const emitter = useMemo((): Emitter => {
const events: Partial<Events> = {}
return {
emit(event, node) {
const source = events[event]
if (source) source.forEach((cb) => cb(node))
},
on(event, cb) {
const source = (events[event] = events[event] || [])!
source.push(cb)
return () => {
events[event] = source.filter((item) => item !== cb)
}
},
}
}, [])

return (
<SlotsContext.Provider value={emitter}>
{props.children}
</SlotsContext.Provider>
)
}

return context
}
function useSlotsContext() {
const context = useContext(SlotsContext)

export interface SlotProps {
name: string | symbol
children?: React.ReactNode
}
if (context === null) {
throw new Error('Must be used inside `Provider`')
}

export function Slot(props: SlotProps) {
const [state, setState] = useState<React.ReactNode>()
return context
}

const emitter = useSlotsContext()
const ref = useRef<Unsubscribe | null>()
function Slot(props: SlotProps<Names>) {
const [state, setState] = useState<React.ReactNode>()

if (ref.current === undefined) {
ref.current = emitter.on(props.name, setState)
}
const emitter = useSlotsContext()
const ref = useRef<Unsubscribe | null>()

useUniversalEffect(() => {
if (ref.current) {
ref.current()
ref.current = null
if (ref.current === undefined) {
ref.current = emitter.on(props.name, setState)
}
return emitter.on(props.name, setState)
}, [emitter, props.name])

return <Fragment>{state === undefined ? props.children : state}</Fragment>
}
useUniversalEffect(() => {
if (ref.current) {
ref.current()
ref.current = null
}
return emitter.on(props.name, setState)
}, [emitter, props.name])

export interface FillProps extends SlotProps {}
return <Fragment>{state === undefined ? props.children : state}</Fragment>
}

export function Fill(props: FillProps) {
const emitter = useSlotsContext()
function Fill(props: FillProps<Names>) {
const emitter = useSlotsContext()

useUniversalEffect(() => {
emitter.emit(props.name, props.children)
}, [emitter, props.name, props.children])
useUniversalEffect(() => {
emitter.emit(props.name, props.children)
}, [emitter, props.name, props.children])

return null
return null
}

return {
Provider,
Slot,
Fill,
}
}

0 comments on commit 645ba25

Please sign in to comment.