diff --git a/packages/react-slots/CHANGELOG.md b/packages/react-slots/CHANGELOG.md index f03d9dd..b239896 100644 --- a/packages/react-slots/CHANGELOG.md +++ b/packages/react-slots/CHANGELOG.md @@ -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. diff --git a/packages/react-slots/src/__tests__/createSlot.test-d.tsx b/packages/react-slots/src/__tests__/createSlot.test-d.tsx index 78edd61..2a877b8 100644 --- a/packages/react-slots/src/__tests__/createSlot.test-d.tsx +++ b/packages/react-slots/src/__tests__/createSlot.test-d.tsx @@ -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() + + return null + }, + }) + + // @ts-expect-error — fallback receives no props + createSlot<{ userId: number }>({ fallback: (props: { userId: number }) => {props.userId} }) + }) + + 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 }) => {props.name} }) + }) + + 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 }>() diff --git a/packages/react-slots/src/__tests__/createSlot.test.tsx b/packages/react-slots/src/__tests__/createSlot.test.tsx index 230a0c9..cce0c21 100644 --- a/packages/react-slots/src/__tests__/createSlot.test.tsx +++ b/packages/react-slots/src/__tests__/createSlot.test.tsx @@ -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: () => empty }) + + const { container } = render() + + expect(texts(container, "i")).toEqual(["empty"]) + }) + + it("does not render fallback when something was inserted before mount", () => { + const slot = createSlot({ fallback: () => empty }) + + slot.api.insert({ Component: () => content }) + + const { container } = render() + + expect(texts(container, "i")).toEqual([]) + expect(texts(container, "b")).toEqual(["content"]) + }) + + it("removes fallback when something is inserted after mount", () => { + const slot = createSlot({ fallback: () => empty }) + + const { container } = render() + + expect(texts(container, "i")).toEqual(["empty"]) + + act(() => { + slot.api.insert({ Component: () => content }) + }) + + expect(texts(container, "i")).toEqual([]) + expect(texts(container, "b")).toEqual(["content"]) + }) + + it("does not bring fallback back after clear", () => { + const slot = createSlot({ fallback: () => empty }) + + slot.api.insert({ Component: () => content }) + + const { container } = render() + + act(() => { + slot.api.clear() + }) + + expect(container.innerHTML).toBe("") + }) + + it("keeps fallback when clear is called before any insert", () => { + const slot = createSlot({ fallback: () => empty }) + + const { container } = render() + + act(() => { + slot.api.clear() + }) + + expect(texts(container, "i")).toEqual(["empty"]) + }) + + it("renders children inserted after clear, still without fallback", () => { + const slot = createSlot({ fallback: () => empty }) + + slot.api.insert({ Component: () => first }) + + const { container } = render() + + act(() => { + slot.api.clear() + }) + + act(() => { + slot.api.insert({ Component: () => second }) + }) + + 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 empty + }, + }) + + const { container, rerender } = render() + + expect(texts(container, "i")).toEqual(["empty"]) + + const before = renders + + rerender() + rerender() + + expect(renders).toBe(before) + }) + + it("fallback latch is shared between two mounted Roots of the same slot", () => { + const slot = createSlot({ fallback: () => empty }) + + const first = render() + const second = render() + + expect(texts(first.container, "i")).toEqual(["empty"]) + expect(texts(second.container, "i")).toEqual(["empty"]) + + act(() => { + slot.api.insert({ Component: () => content }) + }) + + 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: () => empty }) + + const { container } = render() + + expect(texts(container, "i")).toEqual(["empty"]) + + act(() => { + slot.api.insert({ Component: () => content }) + }) + + 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[] = [] diff --git a/packages/react-slots/src/__tests__/store.test.ts b/packages/react-slots/src/__tests__/store.test.ts index 6042b28..801c129 100644 --- a/packages/react-slots/src/__tests__/store.test.ts +++ b/packages/react-slots/src/__tests__/store.test.ts @@ -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 } @@ -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() const second = createStore() expect(first.get()).toBe(second.get()) + }) + + it("cleared state is a stable shared reference, distinct from the initial one", () => { + const first = createStore() + const second = createStore() + + 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() + + 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", () => { diff --git a/packages/react-slots/src/createSlot.tsx b/packages/react-slots/src/createSlot.tsx index c02d2fa..53d2701 100644 --- a/packages/react-slots/src/createSlot.tsx +++ b/packages/react-slots/src/createSlot.tsx @@ -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 = { Root: NamedExoticComponent & object> @@ -23,7 +27,7 @@ type PresenceSlot = Slot & { const projectAllFalse = (items: readonly { id: string }[]): readonly boolean[] => items.length === 0 ? EMPTY_PRESENCE : items.map(() => false) -function createSlot(config: { presence: true }): PresenceSlot +function createSlot(config: SlotConfig & { presence: true }): PresenceSlot function createSlot(config?: SlotConfig): Slot function createSlot(config?: SlotConfig) { if (!config?.presence) { @@ -74,11 +78,23 @@ function createSlot(config?: SlotConfig) { store.insert(item) }) as Payload - const Root = memo((props) => - useStore(store).map((child) => - child.withProps ? : , - ), - ) + const Fallback = config?.fallback ? memo(config.fallback) : null + + const Root = Fallback + ? memo((props) => { + const items = useStore(store) + + if (items.length === 0) return neverInserted(items) ? : null + + return items.map((child) => + child.withProps ? : , + ) + }) + : memo((props) => + useStore(store).map((child) => + child.withProps ? : , + ), + ) return { Root, api: { insert, clear: store.clear } } } @@ -132,11 +148,23 @@ function createSlot(config?: SlotConfig) { store.insert(item) }) as Payload - const Root = memo((props) => - useStore(store).map((child) => - child.withProps ? : , - ), - ) + const Fallback = config.fallback ? memo(config.fallback) : null + + const Root = Fallback + ? memo((props) => { + const items = useStore(store) + + if (items.length === 0) return neverInserted(items) ? : null + + return items.map((child) => + child.withProps ? : , + ) + }) + : memo((props) => + useStore(store).map((child) => + child.withProps ? : , + ), + ) const subscribe = (listener: () => void) => presence.subscribe(listener) diff --git a/packages/react-slots/src/store.ts b/packages/react-slots/src/store.ts index ea432ee..9676afb 100644 --- a/packages/react-slots/src/store.ts +++ b/packages/react-slots/src/store.ts @@ -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 = () => { let state: readonly Item[] = EMPTY @@ -19,7 +24,7 @@ const createStore = () => { const clear = (): void => { if (state.length === 0) return - state = EMPTY + state = CLEARED notify() } @@ -39,5 +44,5 @@ type Store = ReturnType> const useStore = (store: Store): readonly Item[] => useSyncExternalStore(store.subscribe, store.get, store.get) -export { createStore, useStore } +export { createStore, neverInserted, useStore } export type { Store }