Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/react-slots/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ and this project adheres to [Semantic Versioning](http://semver.org).

## Unreleased

### Added

- `createSlot({ fallback })` — a component (no props) rendered while the slot has never received an insert. The first `insert` removes it for good: a later `clear` empties the slot but does not bring the fallback back. Emptiness is list-based — the fallback tracks `insert`/`clear`, not what children render. On the server it renders whenever nothing was inserted at render time, mirroring the client pre-insert state. Composes with `presence: true`; the fallback is not probed and never counts toward `useCount`/`usePresence`.

### Fixed

- `filter` freeze is now concurrent-safe: a render attempt React abandons (an interrupted `startTransition`, a suspended attempt) can no longer leak a never-committed element into the frozen child. The freeze cache moved from a render-written ref to render-phase state.
Expand Down
28 changes: 28 additions & 0 deletions packages/react-slots/src/__tests__/createSlot.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,34 @@ describe("createSlot types", () => {
})
})

it("fallback receives no props, even on a typed slot", () => {
createSlot<{ userId: number }>({
fallback: (props) => {
expectTypeOf(props).toEqualTypeOf<EmptyObject>()

return null
},
})

// @ts-expect-error — fallback receives no props
createSlot<{ userId: number }>({ fallback: (props: { userId: number }) => <b>{props.userId}</b> })
})

it("fallback is optional and forbids props on a propless slot", () => {
createSlot({ fallback: () => null })
createSlot({})

// @ts-expect-error — fallback receives no props
createSlot({ fallback: (props: { name: string }) => <b>{props.name}</b> })
})

it("fallback composes with presence", () => {
const slot = createSlot<{ userId: number }>({ presence: true, fallback: () => null })

expectTypeOf(slot.useCount).toEqualTypeOf<() => number>()
expectTypeOf(slot.usePresence).toEqualTypeOf<() => readonly boolean[]>()
})

it("convention: filter above mapProps — reversed order silently loses narrowing", () => {
const slot = createSlot<{ kind: "str"; text: string } | { kind: "num"; value: number }>()

Expand Down
150 changes: 150 additions & 0 deletions packages/react-slots/src/__tests__/createSlot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,156 @@ describe("createSlot", () => {
expect(new Set(texts(container, "b")).size).toBe(1)
})

it("renders fallback while nothing was ever inserted", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

const { container } = render(<slot.Root />)

expect(texts(container, "i")).toEqual(["empty"])
})

it("does not render fallback when something was inserted before mount", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

slot.api.insert({ Component: () => <b>content</b> })

const { container } = render(<slot.Root />)

expect(texts(container, "i")).toEqual([])
expect(texts(container, "b")).toEqual(["content"])
})

it("removes fallback when something is inserted after mount", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

const { container } = render(<slot.Root />)

expect(texts(container, "i")).toEqual(["empty"])

act(() => {
slot.api.insert({ Component: () => <b>content</b> })
})

expect(texts(container, "i")).toEqual([])
expect(texts(container, "b")).toEqual(["content"])
})

it("does not bring fallback back after clear", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

slot.api.insert({ Component: () => <b>content</b> })

const { container } = render(<slot.Root />)

act(() => {
slot.api.clear()
})

expect(container.innerHTML).toBe("")
})

it("keeps fallback when clear is called before any insert", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

const { container } = render(<slot.Root />)

act(() => {
slot.api.clear()
})

expect(texts(container, "i")).toEqual(["empty"])
})

it("renders children inserted after clear, still without fallback", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

slot.api.insert({ Component: () => <b>first</b> })

const { container } = render(<slot.Root />)

act(() => {
slot.api.clear()
})

act(() => {
slot.api.insert({ Component: () => <b>second</b> })
})

expect(texts(container, "b")).toEqual(["second"])
expect(texts(container, "i")).toEqual([])

act(() => {
slot.api.clear()
})

expect(container.innerHTML).toBe("")
})

it("never re-renders fallback, no matter how Root props churn", () => {
let renders = 0

const slot = createSlot<{ tick: number }>({
fallback: () => {
renders += 1

return <i>empty</i>
},
})

const { container, rerender } = render(<slot.Root tick={1} />)

expect(texts(container, "i")).toEqual(["empty"])

const before = renders

rerender(<slot.Root tick={2} />)
rerender(<slot.Root tick={3} />)

expect(renders).toBe(before)
})

it("fallback latch is shared between two mounted Roots of the same slot", () => {
const slot = createSlot({ fallback: () => <i>empty</i> })

const first = render(<slot.Root />)
const second = render(<slot.Root />)

expect(texts(first.container, "i")).toEqual(["empty"])
expect(texts(second.container, "i")).toEqual(["empty"])

act(() => {
slot.api.insert({ Component: () => <b>content</b> })
})

act(() => {
slot.api.clear()
})

expect(first.container.innerHTML).toBe("")
expect(second.container.innerHTML).toBe("")
})

it("supports fallback on a presence slot", () => {
const slot = createSlot({ presence: true, fallback: () => <i>empty</i> })

const { container } = render(<slot.Root />)

expect(texts(container, "i")).toEqual(["empty"])

act(() => {
slot.api.insert({ Component: () => <b>content</b> })
})

expect(texts(container, "i")).toEqual([])
expect(texts(container, "b")).toEqual(["content"])

act(() => {
slot.api.clear()
})

expect(container.innerHTML).toBe("")
})

it("mapProps receives only props that passed filter", () => {
const slot = createSlot<{ kind: "str"; text: string } | { kind: "num"; value: number }>()
const seen: string[] = []
Expand Down
33 changes: 31 additions & 2 deletions packages/react-slots/src/__tests__/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"
import { createStore } from "../store"
import { createStore, neverInserted } from "../store"

type Item = { order?: number; label?: string }

Expand Down Expand Up @@ -99,16 +99,45 @@ describe("createStore", () => {
expect(snapshot.map((x) => x.label)).toEqual(["a"])
})

it("empty state is the shared EMPTY reference", () => {
it("initial empty state is the shared EMPTY reference", () => {
const first = createStore<Item>()
const second = createStore<Item>()

expect(first.get()).toBe(second.get())
})

it("cleared state is a stable shared reference, distinct from the initial one", () => {
const first = createStore<Item>()
const second = createStore<Item>()

const initial = first.get()

first.insert({})
first.clear()
second.insert({})
second.clear()

expect(first.get()).toBe(second.get())
expect(first.get()).not.toBe(initial)
expect(first.get()).toEqual([])
})

it("neverInserted tells a pristine store from a cleared one", () => {
const store = createStore<Item>()

expect(neverInserted(store.get())).toBe(true)

store.clear()

expect(neverInserted(store.get())).toBe(true)

store.insert({})

expect(neverInserted(store.get())).toBe(false)

store.clear()

expect(neverInserted(store.get())).toBe(false)
})

it("stores are isolated from each other", () => {
Expand Down
56 changes: 42 additions & 14 deletions packages/react-slots/src/createSlot.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { ReactNode } from "react"
import React, { memo, useState, type FunctionComponent, type NamedExoticComponent, type ReactElement } from "react"
import { useSyncExternalStore } from "use-sync-external-store/shim"
import type { Insertable, NormalizedProps, Payload } from "./payload"
import type { EmptyObject, Insertable, NormalizedProps, Payload } from "./payload"
import { createPresenceStore, probe, type PresenceStore } from "./presenceStore"
import { createStore, useStore } from "./store"
import { createStore, neverInserted, useStore } from "./store"

let idCounter = 0

const EMPTY_PRESENCE: readonly boolean[] = []

type SlotConfig = { presence?: boolean | undefined }
type SlotConfig = {
presence?: boolean | undefined
fallback?: ((props: EmptyObject) => ReactNode) | undefined
}

type Slot<T extends Insertable> = {
Root: NamedExoticComponent<NormalizedProps<T> & object>
Expand All @@ -23,7 +27,7 @@ type PresenceSlot<T extends Insertable> = Slot<T> & {
const projectAllFalse = (items: readonly { id: string }[]): readonly boolean[] =>
items.length === 0 ? EMPTY_PRESENCE : items.map(() => false)

function createSlot<T extends Insertable = void>(config: { presence: true }): PresenceSlot<T>
function createSlot<T extends Insertable = void>(config: SlotConfig & { presence: true }): PresenceSlot<T>
function createSlot<T extends Insertable = void>(config?: SlotConfig): Slot<T>
function createSlot<T extends Insertable = void>(config?: SlotConfig) {
if (!config?.presence) {
Expand Down Expand Up @@ -74,11 +78,23 @@ function createSlot<T extends Insertable = void>(config?: SlotConfig) {
store.insert(item)
}) as Payload<T>

const Root = memo<SlotProps>((props) =>
useStore(store).map((child) =>
child.withProps ? <child.Child key={child.id} {...props} /> : <child.Child key={child.id} />,
),
)
const Fallback = config?.fallback ? memo(config.fallback) : null

const Root = Fallback
? memo<SlotProps>((props) => {
const items = useStore(store)

if (items.length === 0) return neverInserted(items) ? <Fallback /> : null

return items.map((child) =>
child.withProps ? <child.Child key={child.id} {...props} /> : <child.Child key={child.id} />,
)
})
: memo<SlotProps>((props) =>
useStore(store).map((child) =>
child.withProps ? <child.Child key={child.id} {...props} /> : <child.Child key={child.id} />,
),
)

return { Root, api: { insert, clear: store.clear } }
}
Expand Down Expand Up @@ -132,11 +148,23 @@ function createSlot<T extends Insertable = void>(config?: SlotConfig) {
store.insert(item)
}) as Payload<T>

const Root = memo<SlotProps>((props) =>
useStore(store).map((child) =>
child.withProps ? <child.Child key={child.id} {...props} /> : <child.Child key={child.id} />,
),
)
const Fallback = config.fallback ? memo(config.fallback) : null

const Root = Fallback
? memo<SlotProps>((props) => {
const items = useStore(store)

if (items.length === 0) return neverInserted(items) ? <Fallback /> : null

return items.map((child) =>
child.withProps ? <child.Child key={child.id} {...props} /> : <child.Child key={child.id} />,
)
})
: memo<SlotProps>((props) =>
useStore(store).map((child) =>
child.withProps ? <child.Child key={child.id} {...props} /> : <child.Child key={child.id} />,
),
)

const subscribe = (listener: () => void) => presence.subscribe(listener)

Expand Down
9 changes: 7 additions & 2 deletions packages/react-slots/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { useSyncExternalStore } from "use-sync-external-store/shim"
import { insertSorted, type WithOrder } from "./insertSorted"

const EMPTY: readonly never[] = []
// Deliberately distinct from EMPTY: snapshot identity is what tells a never-inserted
// store (EMPTY) from an emptied one (CLEARED) — fallback rendering relies on it.
const CLEARED: readonly never[] = []

const neverInserted = (state: readonly unknown[]): boolean => state === EMPTY

const createStore = <Item extends WithOrder>() => {
let state: readonly Item[] = EMPTY
Expand All @@ -19,7 +24,7 @@ const createStore = <Item extends WithOrder>() => {
const clear = (): void => {
if (state.length === 0) return

state = EMPTY
state = CLEARED
notify()
}

Expand All @@ -39,5 +44,5 @@ type Store<Item extends WithOrder> = ReturnType<typeof createStore<Item>>
const useStore = <Item extends WithOrder>(store: Store<Item>): readonly Item[] =>
useSyncExternalStore(store.subscribe, store.get, store.get)

export { createStore, useStore }
export { createStore, neverInserted, useStore }
export type { Store }