Fix Presence reactivity

This commit is contained in:
Mats Bosson 2026-03-29 05:28:35 +07:00
parent 3374349ef2
commit de85abf548
2 changed files with 104 additions and 33 deletions

View File

@ -1,8 +1,11 @@
import { type Accessor, type JSX, Show, children, createEffect, createSignal } from "solid-js"; import { type Accessor, type JSX, Show, createEffect, createSignal, on } from "solid-js";
export interface PresenceChildProps { export interface PresenceChildProps {
/** Whether the content is currently present (tracks props.present directly). */
present: Accessor<boolean>; present: Accessor<boolean>;
/** True briefly after present transitions from false→true. For enter animations. */
opening: Accessor<boolean>; opening: Accessor<boolean>;
/** True briefly after present transitions from true→false. For exit animations. */
closing: Accessor<boolean>; closing: Accessor<boolean>;
} }
@ -11,54 +14,72 @@ export interface PresenceProps {
present: boolean; present: boolean;
/** /**
* When true, keeps children mounted even when present is false. * When true, keeps children mounted even when present is false.
* Useful for controlling exit animations with external libraries. * Use this when an external animation library controls unmounting.
*/ */
forceMount?: boolean; forceMount?: boolean;
children: JSX.Element | ((props: PresenceChildProps) => JSX.Element); children: JSX.Element | ((props: PresenceChildProps) => JSX.Element);
} }
/** /**
* Keeps exiting elements mounted during CSS/JS animations. * Controls mount/unmount of children with enter/exit animation support.
* Emits data-opening and data-closing attributes for CSS-driven transitions. * Exposes opening and closing signals via the children-as-function API.
* *
* Usage with CSS: * Usage with children-as-function:
* .content[data-opening] { animation: fadeIn 200ms; } * <Presence present={open()}>
* .content[data-closing] { animation: fadeOut 150ms; } * {({ opening, closing }) => (
* <div classList={{ opening: opening(), closing: closing() }}></div>
* )}
* </Presence>
*/ */
export function Presence(props: PresenceProps): JSX.Element { export function Presence(props: PresenceProps): JSX.Element {
const [mounted, setMounted] = createSignal(props.present); const [mounted, setMounted] = createSignal(props.present);
const [opening, setOpening] = createSignal(false); const [opening, setOpening] = createSignal(false);
const [closing, setClosing] = createSignal(false); const [closing, setClosing] = createSignal(false);
createEffect(() => { // defer: true skips the initial run — only react to changes after mount.
if (props.present) { createEffect(
on(
() => props.present,
(isPresent) => {
if (isPresent) {
setMounted(true); setMounted(true);
setClosing(false); setClosing(false);
setOpening(true); setOpening(true);
// Clear opening flag after a microtask so CSS can pick it up // Clear opening after a microtask — gives consumers one reactive tick
// to read the opening state before it resets.
queueMicrotask(() => setOpening(false)); queueMicrotask(() => setOpening(false));
} else { } else {
setOpening(false); setOpening(false);
setClosing(true); setClosing(true);
// Keep mounted while closing — consumers use forceMount for animation control // In the baseline, unmount immediately (no animation duration known).
setClosing(false); // With forceMount, the consumer controls the lifecycle externally.
if (!props.forceMount) {
setMounted(false); setMounted(false);
} }
}); queueMicrotask(() => setClosing(false));
}
},
{ defer: true },
),
);
const shouldMount = () => props.forceMount || mounted(); const shouldMount = () => props.forceMount || mounted();
const childProps: PresenceChildProps = { const childProps: PresenceChildProps = {
present: mounted, // Reflect props.present directly — no reactive lag from the mounted signal.
present: () => props.present,
opening, opening,
closing, closing,
}; };
const resolved = children(() => // Wrap in a function so Show evaluates children lazily (only when mounted).
return (
<Show when={shouldMount()}>
{() =>
typeof props.children === "function" typeof props.children === "function"
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps) ? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
: props.children, : props.children
}
</Show>
); );
return <Show when={shouldMount()}>{resolved()}</Show>;
} }

View File

@ -22,20 +22,33 @@ describe("Presence", () => {
expect(queryByTestId("content")).toBeNull(); expect(queryByTestId("content")).toBeNull();
}); });
it("adds data-opening attribute when transitioning in", async () => { it("mounts children when present transitions false→true", async () => {
const [present, setPresent] = createSignal(false); const [present, setPresent] = createSignal(false);
const { queryByTestId } = render(() => ( const { queryByTestId } = render(() => (
<Presence present={present()}> <Presence present={present()}>
<div data-testid="content">hello</div> <div data-testid="content">hello</div>
</Presence> </Presence>
)); ));
expect(queryByTestId("content")).toBeNull();
setPresent(true); setPresent(true);
await Promise.resolve(); await Promise.resolve();
const el = queryByTestId("content"); expect(queryByTestId("content")).toBeTruthy();
expect(el).toBeTruthy();
}); });
it("keeps children mounted with forceMount", () => { it("unmounts children when present transitions true→false", async () => {
const [present, setPresent] = createSignal(true);
const { queryByTestId } = render(() => (
<Presence present={present()}>
<div data-testid="content">hello</div>
</Presence>
));
expect(queryByTestId("content")).toBeTruthy();
setPresent(false);
await Promise.resolve();
expect(queryByTestId("content")).toBeNull();
});
it("keeps children mounted with forceMount when present is false", () => {
const { getByTestId } = render(() => ( const { getByTestId } = render(() => (
<Presence present={false} forceMount> <Presence present={false} forceMount>
<div data-testid="content">hello</div> <div data-testid="content">hello</div>
@ -43,4 +56,41 @@ describe("Presence", () => {
)); ));
expect(getByTestId("content")).toBeTruthy(); expect(getByTestId("content")).toBeTruthy();
}); });
it("exposes opening signal via children-as-function", async () => {
const [present, setPresent] = createSignal(false);
let capturedOpening = false;
render(() => (
<Presence present={present()}>
{({ opening }) => {
if (opening()) capturedOpening = true;
return <div data-testid="content">hello</div>;
}}
</Presence>
));
setPresent(true);
// opening is true for one microtask after transitioning in
await Promise.resolve();
expect(capturedOpening).toBe(true);
});
it("exposes closing signal via children-as-function", async () => {
const [present, setPresent] = createSignal(true);
let capturedClosing = false;
render(() => (
<Presence present={present()} forceMount>
{({ closing }) => {
if (closing()) capturedClosing = true;
return <div data-testid="content">hello</div>;
}}
</Presence>
));
setPresent(false);
await Promise.resolve();
expect(capturedClosing).toBe(true);
});
}); });