Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .changeset/presence-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@solid-primitives/presence": major
---

Migrate to Solid.js v2.0 (beta.10)

## Breaking Changes

**Peer dependency**: `solid-js@^2.0.0-beta.10` is now required.

### `@solid-primitives/presence`

- `createEffect` calls converted to the split compute/apply pattern required by Solid 2.0; cleanup is returned from the apply phase instead of calling `onCleanup`
- Internal signals now use `{ ownedWrite: true }` to allow writes from the effect apply phases
- No changes to the public `createPresence` API or `PresenceResult` type
4 changes: 2 additions & 2 deletions packages/presence/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@
"@solid-primitives/utils": "workspace:^"
},
"peerDependencies": {
"solid-js": "^1.6.12"
"solid-js": "^2.0.0-beta.10"
},
"typesVersions": {},
"devDependencies": {
"solid-js": "^1.9.7"
"solid-js": "^2.0.0-beta.10"
}
}
127 changes: 63 additions & 64 deletions packages/presence/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,8 @@ See https://github.com/amannn/react-hooks/tree/main/packages/use-presence

*/

import {
createSignal,
createEffect,
onCleanup,
type Accessor,
createMemo,
untrack,
} from "solid-js";
import { type MaybeAccessor, asAccessor } from "@solid-primitives/utils";
import { createSignal, createEffect, type Accessor, createMemo, untrack } from "solid-js";
import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";

export type SharedTransitionConfig = {
/** Duration in milliseconds used both for enter and exit transitions. */
Expand Down Expand Up @@ -56,53 +49,50 @@ function createPresenceBase(

const initialSource = untrack(source);
const initialState = options.initialEnter ? false : initialSource;
const [isVisible, setIsVisible] = createSignal(initialState);
const [isMounted, setIsMounted] = createSignal(initialSource);
const [hasEntered, setHasEntered] = createSignal(initialState);
const [isVisible, setIsVisible] = createSignal(initialState, INTERNAL_OPTIONS);
const [isMounted, setIsMounted] = createSignal(initialSource, INTERNAL_OPTIONS);
const [hasEntered, setHasEntered] = createSignal(initialState, INTERNAL_OPTIONS);

const isExiting = createMemo(() => isMounted() && !source());
const isEntering = createMemo(() => source() && !hasEntered());
const isAnimating = createMemo(() => isEntering() || isExiting());

createEffect(() => {
if (source()) {
// `animateVisible` needs to be set to `true` in a second step, as
// when both flags would be flipped at the same time, there would
// be no transition. See the second effect below.
setIsMounted(true);
} else {
setHasEntered(false);
setIsVisible(false);

const timeoutId = setTimeout(() => {
setIsMounted(false);
}, exitDuration());

onCleanup(() => clearTimeout(timeoutId));
}
});

createEffect(() => {
if (source() && isMounted() && !isVisible()) {
document.body.offsetHeight; // force reflow

const animationFrameId = requestAnimationFrame(() => {
setIsVisible(true);
});

onCleanup(() => cancelAnimationFrame(animationFrameId));
}
});

createEffect(() => {
if (isVisible() && !hasEntered()) {
const timeoutId = setTimeout(() => {
setHasEntered(true);
}, enterDuration());

onCleanup(() => clearTimeout(timeoutId));
}
});
createEffect(
() => source(),
isActive => {
if (isActive) {
setIsMounted(true);
} else {
setHasEntered(false);
setIsVisible(false);
const timeoutId = setTimeout(() => setIsMounted(false), exitDuration());
return () => clearTimeout(timeoutId);
}
},
);

createEffect(
() => source() && isMounted() && !isVisible(),
shouldAnimate => {
if (shouldAnimate) {
// Force reflow so the initial invisible state is painted before
// isVisible becomes true, enabling CSS transitions to fire.
document.body.offsetHeight;
const animationFrameId = requestAnimationFrame(() => setIsVisible(true));
return () => cancelAnimationFrame(animationFrameId);
}
},
);

createEffect(
() => isVisible() && !hasEntered(),
shouldTrackEnter => {
if (shouldTrackEnter) {
const timeoutId = setTimeout(() => setHasEntered(true), enterDuration());
return () => clearTimeout(timeoutId);
}
},
);

return {
isMounted,
Expand Down Expand Up @@ -154,24 +144,33 @@ export function createPresence<TItem>(
options: Options,
): PresenceResult<TItem> {
const initial = untrack(item);
const [mountedItem, setMountedItem] = createSignal(initial);
const [shouldBeMounted, setShouldBeMounted] = createSignal(itemShouldBeMounted(initial));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [mountedItem, setMountedItem] = createSignal<TItem | undefined>(initial as any, INTERNAL_OPTIONS);
const [shouldBeMounted, setShouldBeMounted] = createSignal(itemShouldBeMounted(initial), INTERNAL_OPTIONS);
const { isMounted, ...rest } = createPresenceBase(shouldBeMounted, options);

createEffect(() => {
if (mountedItem() !== item()) {
if (isMounted()) {
createEffect(
() => ({
currentItem: item(),
mounted: mountedItem(),
isM: isMounted(),
}),
({ currentItem, mounted, isM }) => {
if (mounted !== currentItem) {
if (isM) {
setShouldBeMounted(false);
} else if (itemShouldBeMounted(currentItem)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setMountedItem(currentItem as any);
setShouldBeMounted(true);
}
} else if (!itemShouldBeMounted(currentItem)) {
setShouldBeMounted(false);
} else if (itemShouldBeMounted(item())) {
setMountedItem(() => item());
} else if (itemShouldBeMounted(currentItem)) {
setShouldBeMounted(true);
}
} else if (!itemShouldBeMounted(item())) {
setShouldBeMounted(false);
} else if (itemShouldBeMounted(item())) {
setShouldBeMounted(true);
}
});
},
);

return {
...rest,
Expand Down
181 changes: 98 additions & 83 deletions packages/presence/test/createPresence.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterAll, beforeAll } from "vitest";
import { createRoot, createSignal } from "solid-js";
import { type Accessor, createRoot, createSignal, flush } from "solid-js";
import { createPresence } from "../src/index.js";

beforeAll(() => {
Expand Down Expand Up @@ -74,64 +74,71 @@ describe("createPresence", () => {
dispose();
}));

it("is in a mounted & animating state for the transitionDuration and then it is only in a mounted state", () =>
createRoot(async dispose => {
const [shouldRender, setShouldRender] = createSignal(false);
const transitionDuration = 50;
const { isMounted, isAnimating } = createPresence(shouldRender, {
transitionDuration,
});
expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
setShouldRender(true);
await vi.advanceTimersByTimeAsync(0);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(false);
setShouldRender(false);
await vi.advanceTimersByTimeAsync(0);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
dispose();
}));

it("swaps between a mounted & unmounted state based on unique enter & exit transition states", () =>
createRoot(async dispose => {
const [shouldRender, setShouldRender] = createSignal(false);
// using longer durations to ensure that the transitionTimeOffset
// is doesn't play into the test timings
const enterDuration = 300;
const exitDuration = 150;
const { isMounted, isAnimating } = createPresence(shouldRender, {
enterDuration,
exitDuration,
});
expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
setShouldRender(true);
await vi.advanceTimersByTimeAsync(0);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
// ensure that we're not done animating after the shorter time
await vi.advanceTimersByTimeAsync(exitDuration + transitionTimeOffset);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(exitDuration + transitionTimeOffset);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(false);
setShouldRender(false);
await vi.advanceTimersByTimeAsync(0);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(exitDuration + transitionTimeOffset);
expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
dispose();
}));
it("is in a mounted & animating state for the transitionDuration and then it is only in a mounted state", async () => {
const [shouldRender, setShouldRender] = createSignal(false);
const transitionDuration = 50;

let isMounted!: Accessor<boolean>;
let isAnimating!: Accessor<boolean>;
const dispose = createRoot(d => {
({ isMounted, isAnimating } = createPresence(shouldRender, { transitionDuration }));
return d;
});

expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
setShouldRender(true);
flush();
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(false);
setShouldRender(false);
flush();
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
dispose();
});

it("swaps between a mounted & unmounted state based on unique enter & exit transition states", async () => {
const [shouldRender, setShouldRender] = createSignal(false);
// using longer durations to ensure that the transitionTimeOffset
// doesn't play into the test timings
const enterDuration = 300;
const exitDuration = 150;

let isMounted!: Accessor<boolean>;
let isAnimating!: Accessor<boolean>;
const dispose = createRoot(d => {
({ isMounted, isAnimating } = createPresence(shouldRender, { enterDuration, exitDuration }));
return d;
});

expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
setShouldRender(true);
flush();
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
// ensure that we're not done animating after the shorter time
await vi.advanceTimersByTimeAsync(exitDuration + transitionTimeOffset);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(exitDuration + transitionTimeOffset);
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(false);
setShouldRender(false);
flush();
expect(isMounted()).toBe(true);
expect(isAnimating()).toBe(true);
await vi.advanceTimersByTimeAsync(exitDuration + transitionTimeOffset);
expect(isMounted()).toBe(false);
expect(isAnimating()).toBe(false);
dispose();
});

// data switching tests

Expand Down Expand Up @@ -174,29 +181,37 @@ describe("createPresence", () => {
dispose();
}));

it("initially exchanges the data over the transition time", () =>
createRoot(async dispose => {
const [data, setData] = createSignal<Data>("foo");
const transitionDuration = 150;
const { mountedItem, isAnimating, isEntering, isExiting } = createPresence(data, {
it("initially exchanges the data over the transition time", async () => {
const [data, setData] = createSignal<Data>("foo");
const transitionDuration = 150;

let mountedItem!: Accessor<Data | undefined>;
let isAnimating!: Accessor<boolean>;
let isEntering!: Accessor<boolean>;
let isExiting!: Accessor<boolean>;
const dispose = createRoot(d => {
({ mountedItem, isAnimating, isEntering, isExiting } = createPresence(data, {
transitionDuration,
});
expect(isAnimating()).toBe(false);
setData("bar");
await vi.advanceTimersByTimeAsync(0);
expect(isAnimating()).toBe(true);
expect(mountedItem()).toBe("foo");
expect(isExiting()).toBe(true);
expect(isEntering()).toBe(false);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isAnimating()).toBe(true);
expect(mountedItem()).toBe("bar");
expect(isEntering()).toBe(true);
expect(isExiting()).toBe(false);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isAnimating()).toBe(false);
expect(isEntering()).toBe(false);
expect(isExiting()).toBe(false);
dispose();
}));
}));
return d;
});

expect(isAnimating()).toBe(false);
setData("bar");
flush();
expect(isAnimating()).toBe(true);
expect(mountedItem()).toBe("foo");
expect(isExiting()).toBe(true);
expect(isEntering()).toBe(false);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isAnimating()).toBe(true);
expect(mountedItem()).toBe("bar");
expect(isEntering()).toBe(true);
expect(isExiting()).toBe(false);
await vi.advanceTimersByTimeAsync(transitionDuration + transitionTimeOffset);
expect(isAnimating()).toBe(false);
expect(isEntering()).toBe(false);
expect(isExiting()).toBe(false);
dispose();
});
});
Loading