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

View File

@ -22,20 +22,33 @@ describe("Presence", () => {
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 { queryByTestId } = render(() => (
<Presence present={present()}>
<div data-testid="content">hello</div>
</Presence>
));
expect(queryByTestId("content")).toBeNull();
setPresent(true);
await Promise.resolve();
const el = queryByTestId("content");
expect(el).toBeTruthy();
expect(queryByTestId("content")).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(() => (
<Presence present={false} forceMount>
<div data-testid="content">hello</div>
@ -43,4 +56,41 @@ describe("Presence", () => {
));
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);
});
});