From 94533542f56bec01f1de09835c3fb760923f164a Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 10 May 2026 10:43:54 -0400 Subject: [PATCH 1/4] Migrate to Solid 2.0 beta 10 --- .changeset/lifecycle-solid2-migration.md | 17 +++++++++++++++++ packages/lifecycle/package.json | 6 ++++-- packages/lifecycle/src/index.ts | 10 +++++----- packages/lifecycle/test/index.test.ts | 17 ++++++++--------- packages/lifecycle/test/server.test.ts | 2 +- pnpm-lock.yaml | 7 +++++-- 6 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 .changeset/lifecycle-solid2-migration.md diff --git a/.changeset/lifecycle-solid2-migration.md b/.changeset/lifecycle-solid2-migration.md new file mode 100644 index 000000000..e3684409c --- /dev/null +++ b/.changeset/lifecycle-solid2-migration.md @@ -0,0 +1,17 @@ +--- +"@solid-primitives/lifecycle": major +--- + +Migrate to Solid.js v2.0 (beta.10) + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. + +- `isServer` is now imported from `@solidjs/web` (was `solid-js/web`) +- `onMount` replaced with `onSettled` — `createIsMounted` now schedules its signal update after the owner settles +- `getListener` replaced with `getObserver` — `isHydrated` uses `getObserver` to detect reactive context +- `sharedConfig.context` replaced with `sharedConfig.hydrating` — `isHydrated` now reads the boolean `hydrating` flag +- `renderToString` in server tests now imported from `@solidjs/web` (was `solid-js/web`) + +No changes to the public API: `createIsMounted`, `isHydrated`, and `onElementConnect` signatures are unchanged. diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json index 4796f9260..68379ca61 100644 --- a/packages/lifecycle/package.json +++ b/packages/lifecycle/package.json @@ -54,9 +54,11 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.10", + "solid-js": "^2.0.0-beta.10" }, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" } } diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index 630002f8b..b1f4cdf17 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -1,12 +1,12 @@ import { type Accessor, createSignal, - getListener, + getObserver, onCleanup, - onMount, + onSettled, sharedConfig, } from "solid-js"; -import { isServer } from "solid-js/web"; +import { isServer } from "@solidjs/web"; /** * @returns a signal accessor that will return a `false` initially, @@ -23,7 +23,7 @@ import { isServer } from "solid-js/web"; export function createIsMounted(): Accessor { if (isServer) return () => false; const [isMounted, setIsMounted] = createSignal(false); - onMount(() => setIsMounted(true)); + onSettled(() => { setIsMounted(true); }); return isMounted; } @@ -39,7 +39,7 @@ export function createIsMounted(): Accessor { * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/lifecycle#isHydrated */ export const isHydrated = (): boolean => - !isServer && (!sharedConfig.context || (!!getListener() && createIsMounted()())); + !isServer && (!sharedConfig.hydrating || (!!getObserver() && createIsMounted()())); /** * Calls the {@link fn} callback when the {@link el} is connected to the DOM. diff --git a/packages/lifecycle/test/index.test.ts b/packages/lifecycle/test/index.test.ts index 20098018c..19f76c07e 100644 --- a/packages/lifecycle/test/index.test.ts +++ b/packages/lifecycle/test/index.test.ts @@ -1,20 +1,19 @@ import { describe, test, expect } from "vitest"; -import { createEffect, createRoot } from "solid-js"; +import { createRoot, flush } from "solid-js"; import { createIsMounted, isHydrated } from "../src/index.js"; describe("createIsMounted", () => { test("createIsMounted", () => { - createRoot(dispose => { - const isMounted = createIsMounted(); + let isMounted!: () => boolean; + const dispose = createRoot(d => { + isMounted = createIsMounted(); expect(isMounted()).toBe(false); - - createEffect(() => { - expect(isMounted()).toBe(true); - dispose(); - }); + return d; }); - expect(createIsMounted()()).toBe(true); + flush(); + expect(isMounted()).toBe(true); + dispose(); }); }); diff --git a/packages/lifecycle/test/server.test.ts b/packages/lifecycle/test/server.test.ts index 127bde9bb..d301ac69e 100644 --- a/packages/lifecycle/test/server.test.ts +++ b/packages/lifecycle/test/server.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "vitest"; import { createRoot } from "solid-js"; import { createIsMounted, isHydrated } from "../src/index.js"; -import { renderToString } from "solid-js/web"; +import { renderToString } from "@solidjs/web"; describe("createIsMounted", () => { test("createIsMounted", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 667e6a013..38bce60bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,9 +517,12 @@ importers: packages/lifecycle: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/list: devDependencies: From c193d434fa5f168c74d9e7f72678cc9845040293 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 23 May 2026 17:10:25 -0400 Subject: [PATCH 2/4] Update beta 14 --- packages/lifecycle/package.json | 8 ++++---- pnpm-lock.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json index 68379ca61..d62e226f9 100644 --- a/packages/lifecycle/package.json +++ b/packages/lifecycle/package.json @@ -54,11 +54,11 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "@solidjs/web": "^2.0.0-beta.10", - "solid-js": "^2.0.0-beta.10" + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" }, "devDependencies": { - "@solidjs/web": "2.0.0-beta.10", - "solid-js": "2.0.0-beta.10" + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ea7079db..86afcdbbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,11 +559,11 @@ importers: packages/lifecycle: devDependencies: '@solidjs/web': - specifier: 2.0.0-beta.10 - version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: 2.0.0-beta.10 - version: 2.0.0-beta.10 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/list: devDependencies: From 9375d35d96e5fc1b8b4612fdbd91573278d23324 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 30 May 2026 13:28:20 -0400 Subject: [PATCH 3/4] Check if multiple isMounted signals created --- packages/lifecycle/test/index.test.ts | 82 ++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle/test/index.test.ts b/packages/lifecycle/test/index.test.ts index 19f76c07e..17ef787d9 100644 --- a/packages/lifecycle/test/index.test.ts +++ b/packages/lifecycle/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { createRoot, flush } from "solid-js"; +import { createMemo, createRoot, createSignal, flush, onSettled, sharedConfig } from "solid-js"; import { createIsMounted, isHydrated } from "../src/index.js"; describe("createIsMounted", () => { @@ -15,10 +15,90 @@ describe("createIsMounted", () => { expect(isMounted()).toBe(true); dispose(); }); + + test("setIsMounted(true) is applied synchronously within the triggering flush", () => { + // Confirms that flush() inside onSettled is not needed: the write is already + // visible to subsequent onSettled callbacks registered in the same owner. + let readInLaterOnSettled: boolean | undefined; + const dispose = createRoot(d => { + const isMounted = createIsMounted(); // registers onSettled #1: setIsMounted(true) + onSettled(() => { // registers onSettled #2 + readInLaterOnSettled = isMounted(); + }); + return d; + }); + + flush(); + expect(readInLaterOnSettled).toBe(true); + dispose(); + }); }); describe("isHydrated", () => { test("isHydrated", () => { expect(isHydrated()).toBe(true); }); + + test("multiple isMounted signals created during hydration all resolve after hydration ends", () => { + // When isHydrated() is called in a reactive scope multiple times during hydration + // (e.g. the computation re-runs due to another signal change), each call creates a + // distinct isMounted signal. They should all resolve to true once hydration ends. + sharedConfig.hydrating = true; + + let isMounted1!: () => boolean; + let isMounted2!: () => boolean; + + const dispose = createRoot(d => { + isMounted1 = createIsMounted(); + isMounted2 = createIsMounted(); + return d; + }); + + try { + expect(isMounted1()).toBe(false); + expect(isMounted2()).toBe(false); + + sharedConfig.hydrating = false; + flush(); + + expect(isMounted1()).toBe(true); + expect(isMounted2()).toBe(true); + } finally { + dispose(); + sharedConfig.hydrating = false; + } + }); + + test("isHydrated reactive computation stabilises after exactly one post-hydration re-run", () => { + // With sharedConfig.hydrating = true, a memo calling isHydrated() creates an + // isMounted signal and returns false. Once hydration ends and flush() is called, + // onSettled fires and the signal becomes true — the memo re-runs exactly once + // and returns true, with no further cascade. + sharedConfig.hydrating = true; + + let trueCount = 0; + let hydrated!: () => boolean; + + const dispose = createRoot(d => { + hydrated = createMemo(() => { + const result = isHydrated(); + if (result) trueCount++; + return result; + }); + return d; + }); + + try { + expect(hydrated()).toBe(false); + + sharedConfig.hydrating = false; // hydration ends before flush, as in production + flush(); // onSettled fires → isMounted=true → memo re-runs once → returns true + + expect(hydrated()).toBe(true); + expect(trueCount).toBe(1); // no cascade + } finally { + dispose(); + sharedConfig.hydrating = false; + } + }); }); From dfc3ba8ef1640ec0542ef21f0d697a9a6dcbf9df Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 30 May 2026 13:45:12 -0400 Subject: [PATCH 4/4] Added stories --- packages/lifecycle/README.md | 5 +- packages/lifecycle/dev/index.tsx | 33 ---- packages/lifecycle/package.json | 1 - .../lifecycle/stories/lifecycle.stories.tsx | 161 ++++++++++++++++++ 4 files changed, 162 insertions(+), 38 deletions(-) delete mode 100644 packages/lifecycle/dev/index.tsx create mode 100644 packages/lifecycle/stories/lifecycle.stories.tsx diff --git a/packages/lifecycle/README.md b/packages/lifecycle/README.md index 85b295297..d2d3be6ee 100644 --- a/packages/lifecycle/README.md +++ b/packages/lifecycle/README.md @@ -10,6 +10,7 @@ Package providing extra layer of lifecycle primitives for Solid. +- [**Docs (Storybook)**](https://primitives.solidjs.community/storybook/?path=/docs/reactivity-lifecycle--docs) - [`createIsMounted`](#createismounted) - Returns a boolean signal indicating whether the component is mounted or not. - [`isHydrated`](#ishydrated) - A signal with the same behavior as [`isHydrating`](#ishydrating) but this one focused only on client-side updates. - [`onElementConnect`](#onelementconnect) - Calls the given callback when the target element is connected to the DOM. @@ -119,10 +120,6 @@ However, it is not certain that the elements are actually connected to the DOM w /> ``` -## Demo - -You can see the primitives in action in the following sandbox: https://primitives.solidjs.community/playground/lifecycle/ - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/lifecycle/dev/index.tsx b/packages/lifecycle/dev/index.tsx deleted file mode 100644 index 25347b0a1..000000000 --- a/packages/lifecycle/dev/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { children, createSignal, type JSX, onMount } from "solid-js"; - -import { onElementConnect } from "../src/index.js"; - -function Button() { - const [count, setCount] = createSignal(1); - const increment = () => setCount(count() + 1); - - let ref!: HTMLButtonElement; - - onMount(() => { - console.log("onMount", ref.isConnected); - - onElementConnect(ref, () => { - console.log("onConnect", ref.isConnected); - }); - }); - - return ( - - ); -} - -function App() { - const r = children(() => + + + + + + ); + }, +}); + +export const ClientOnlyGate = meta.story({ + name: "Client-only render gate", + parameters: { + docs: { + description: { + story: + "`isHydrated()` returns `true` once the owner has cleared hydration — always `true` in a CSR context like Storybook. Putting it inside a `createMemo` implements a lightweight `ClientOnly` gate: the memo short-circuits to `false` on the server and during hydration, revealing its children only once on the client. The viewport values below are client-only and would be absent in an SSR render.", + }, + }, + }, + render: () => { + const clientContent = createMemo( + () => + isHydrated() && ( + <> + + + + ), + ); + + return ( + + + +
{clientContent()}
+
+
+ ); + }, +}); + +export const ElementConnectLog = meta.story({ + name: "Element connect callback", + parameters: { + docs: { + description: { + story: + "`onElementConnect` fires its callback the moment the target element becomes connected to the DOM. If `el.isConnected` is already `true` when the `ref` callback runs, it calls synchronously; otherwise it waits via a `ResizeObserver`. Toggle the element to see a timestamped log entry on each reconnection.", + }, + }, + }, + render: () => { + const [show, setShow] = createSignal(true); + const [log, setLog] = createSignal<{ label: string; time: string }[]>([]); + + const addEntry = () => { + const time = new Date().toLocaleTimeString(); + setLog(prev => [{ label: "connected", time }, ...prev].slice(0, 5)); + }; + + return ( + + + + + +
onElementConnect(el, addEntry)} + style={{ + padding: "0.6rem 0.9rem", + background: "#f0fdf4", + border: "1px solid #86efac", + "border-radius": "6px", + "font-size": "0.875rem", + color: "#166534", + }} + > + Connected element +
+
+ +
+ ); + }, +});