From de85abf54863c9d2b54ea4ec7feda6cd78766fbf Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 05:28:35 +0700 Subject: [PATCH] Fix Presence reactivity --- .../core/src/utilities/presence/presence.tsx | 79 ++++++++++++------- .../core/tests/utilities/presence.test.tsx | 58 +++++++++++++- 2 files changed, 104 insertions(+), 33 deletions(-) diff --git a/packages/core/src/utilities/presence/presence.tsx b/packages/core/src/utilities/presence/presence.tsx index f062b80..169bb78 100644 --- a/packages/core/src/utilities/presence/presence.tsx +++ b/packages/core/src/utilities/presence/presence.tsx @@ -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; + /** True briefly after present transitions from false→true. For enter animations. */ opening: Accessor; + /** True briefly after present transitions from true→false. For exit animations. */ closing: Accessor; } @@ -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: + * + * {({ opening, closing }) => ( + *
+ * )} + *
*/ 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 ( + + {() => + typeof props.children === "function" + ? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps) + : props.children + } + ); - - return {resolved()}; } diff --git a/packages/core/tests/utilities/presence.test.tsx b/packages/core/tests/utilities/presence.test.tsx index b4377ed..d096cf0 100644 --- a/packages/core/tests/utilities/presence.test.tsx +++ b/packages/core/tests/utilities/presence.test.tsx @@ -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(() => (
hello
)); + 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(() => ( + +
hello
+
+ )); + 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(() => (
hello
@@ -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(() => ( + + {({ opening }) => { + if (opening()) capturedOpening = true; + return
hello
; + }} +
+ )); + + 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(() => ( + + {({ closing }) => { + if (closing()) capturedClosing = true; + return
hello
; + }} +
+ )); + + setPresent(false); + await Promise.resolve(); + expect(capturedClosing).toBe(true); + }); });