Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/lifecycle-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 1 addition & 4 deletions packages/lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
33 changes: 0 additions & 33 deletions packages/lifecycle/dev/index.tsx

This file was deleted.

7 changes: 4 additions & 3 deletions packages/lifecycle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,17 @@
},
"typesVersions": {},
"scripts": {
"dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
"build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
"vitest": "vitest -c ../../configs/vitest.config.ts",
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr"
},
"peerDependencies": {
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.14",
"solid-js": "^2.0.0-beta.14"
},
"devDependencies": {
"solid-js": "^1.9.7"
"@solidjs/web": "2.0.0-beta.14",
"solid-js": "2.0.0-beta.14"
}
}
10 changes: 5 additions & 5 deletions packages/lifecycle/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,7 +23,7 @@ import { isServer } from "solid-js/web";
export function createIsMounted(): Accessor<boolean> {
if (isServer) return () => false;
const [isMounted, setIsMounted] = createSignal(false);
onMount(() => setIsMounted(true));
onSettled(() => { setIsMounted(true); });
return isMounted;
}

Expand All @@ -39,7 +39,7 @@ export function createIsMounted(): Accessor<boolean> {
* @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()()));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that create mutliple isMounted signals in the worst case, since we're async now? Maybe flush() the signal so it is synchronous.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flush doesn't work in onSettled or createTrackedEffect. I added a test to verify if this is a concern at all. It demonstrates the property: a second onSettled registered after createIsMounted can already read isMounted() as true, proving that setIsMounted(true) is applied synchronously within the flush which is exactly why flush() inside onSettled is maybe not needed.

This one is tricky, you may want to study the tests I added more closely and run it directly.


/**
* Calls the {@link fn} callback when the {@link el} is connected to the DOM.
Expand Down
161 changes: 161 additions & 0 deletions packages/lifecycle/stories/lifecycle.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { createMemo, createSignal, Show } from "solid-js";
import preview from "../../../.storybook/preview.js";
import { createIsMounted, isHydrated, onElementConnect } from "@solid-primitives/lifecycle";
import readme from "../README.md?raw";
import {
BoolRow,
Button,
ButtonRow,
Card,
Container,
EventLog,
Section,
StatRow,
} from "../../../.storybook/ui/index.js";

const meta = preview.meta({
title: "Reactivity/Lifecycle",
tags: ["autodocs"],
parameters: {
layout: "centered",
docs: {
description: {
component: readme,
},
},
},
});

export default meta;

export const RefReadGatedOnMount = meta.story({
name: "Ref read gated on mount",
parameters: {
docs: {
description: {
story:
"`createIsMounted` returns `false` on the initial synchronous render and flips to `true` after `onSettled`. Use it to guard any DOM read that needs a connected element — here `offsetWidth` — so the computation never runs before the ref is live. Click **Unmount → Remount** to watch the `false → true` transition.",
},
},
},
render: () => {
const [show, setShow] = createSignal(true);

const Demo = () => {
let ref!: HTMLDivElement;
const isMounted = createIsMounted();
const width = createMemo(() => (isMounted() ? ref.offsetWidth : 0));

return (
<Card>
<div
ref={ref}
style={{
padding: "0.6rem 0.9rem",
background: "#f1f5f9",
"border-radius": "6px",
"font-size": "0.875rem",
color: "#334155",
}}
>
Measured element
</div>
<BoolRow label="isMounted()" value={isMounted()} />
<StatRow label="offsetWidth" value={isMounted() ? `${width()}px` : "—"} />
</Card>
);
};

return (
<Container width={280}>
<ButtonRow>
<Button onClick={() => setShow(v => !v)} variant="outline">
{show() ? "Unmount" : "Remount"}
</Button>
</ButtonRow>
<Show when={show()}>
<Demo />
</Show>
</Container>
);
},
});

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() && (
<>
<StatRow label="window.innerWidth" value={`${window.innerWidth}px`} />
<StatRow label="window.innerHeight" value={`${window.innerHeight}px`} />
</>
),
);

return (
<Container width={280}>
<Card>
<BoolRow label="isHydrated()" value={isHydrated()} />
<Section title="Client-only content">{clientContent()}</Section>
</Card>
</Container>
);
},
});

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 (
<Container width={280}>
<ButtonRow>
<Button onClick={() => setShow(v => !v)} variant="outline">
{show() ? "Unmount" : "Remount"}
</Button>
</ButtonRow>
<Show when={show()}>
<div
ref={el => 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
</div>
</Show>
<EventLog entries={log()} />
</Container>
);
},
});
93 changes: 86 additions & 7 deletions packages/lifecycle/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,104 @@
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", () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should test that the isMounted signal is not needlessly created multiple times.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a few

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();
});
});

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;
}
});
});
Loading