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/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 +
+
+ +
+ ); + }, +}); diff --git a/packages/lifecycle/test/index.test.ts b/packages/lifecycle/test/index.test.ts index 20098018c..17ef787d9 100644 --- a/packages/lifecycle/test/index.test.ts +++ b/packages/lifecycle/test/index.test.ts @@ -1,20 +1,36 @@ import { describe, test, expect } from "vitest"; -import { createEffect, createRoot } from "solid-js"; +import { createMemo, createRoot, createSignal, flush, onSettled, sharedConfig } 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); + return d; + }); + + flush(); + expect(isMounted()).toBe(true); + dispose(); + }); - createEffect(() => { - 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; }); - expect(createIsMounted()()).toBe(true); + flush(); + expect(readInLaterOnSettled).toBe(true); + dispose(); }); }); @@ -22,4 +38,67 @@ 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; + } + }); }); 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 ed77c10ff..4be0ee8cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,9 +592,12 @@ importers: packages/lifecycle: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/list: devDependencies: