diff --git a/.changeset/lifecycle-on-initial-render-deferred.md b/.changeset/lifecycle-on-initial-render-deferred.md new file mode 100644 index 000000000..17a600cc5 --- /dev/null +++ b/.changeset/lifecycle-on-initial-render-deferred.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/lifecycle": minor +--- + +Add `onInitialRender` and `onDeferred` primitives for post-hydration execution and idle/delayed scheduling with owner preservation. diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index 630002f8b..e97c9fa46 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -2,24 +2,15 @@ import { type Accessor, createSignal, getListener, + getOwner, onCleanup, onMount, + runWithOwner, sharedConfig, + type Owner, } from "solid-js"; import { isServer } from "solid-js/web"; -/** - * @returns a signal accessor that will return a `false` initially, - * and then update to `true` once the owner is mounted. - * @example - * ```tsx - * let ref: HTMLElement - * const isMounted = createIsMounted(); - * const windowWidth = createMemo(() => isMounted() ? ref.offsetWidth : 0) - *
{windowWidth()}
- * ``` - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/lifecycle#createIsMounted - */ export function createIsMounted(): Accessor { if (isServer) return () => false; const [isMounted, setIsMounted] = createSignal(false); @@ -27,38 +18,9 @@ export function createIsMounted(): Accessor { return isMounted; } -/** - * @returns a `boolean` value representing if the hydration process of the current owner is complete. - * - * - `false` during SSR - * - `false` on the client if the component evaluation is during a hydration process. - * - `true` on the client if the component evaluates after hydration or during clinet-side rendering. - * - * Switching from `false` to `true` will trigger the signal to update. - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/lifecycle#isHydrated - */ export const isHydrated = (): boolean => !isServer && (!sharedConfig.context || (!!getListener() && createIsMounted()())); -/** - * Calls the {@link fn} callback when the {@link el} is connected to the DOM. - * @param el target element - * @param fn callback - * @example - * ```tsx - *
{ - * el.isConnected // => often false - * onMount(() => { - * el.isConnected // => often true - * }) - * onConnect(el, () => { - * el.isConnected // => always true - * }) - * }} /> - * ``` - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/lifecycle#onConnect - */ export function onElementConnect(el: Element, fn: VoidFunction): void { if (isServer) return; if (el.isConnected) return fn(); @@ -68,3 +30,90 @@ export function onElementConnect(el: Element, fn: VoidFunction): void { observer.observe(el); onCleanup(() => observer.disconnect()); } + +export interface DeferredOptions { + delayMs?: number; + idle?: boolean; + idleTimeout?: number; +} + +export function onInitialRender(fn: () => void | Promise): void { + if (isServer) return; + + const owner: Owner | null = getOwner(); + + onMount(() => { + let active = true; + + onCleanup(() => { + active = false; + }); + + queueMicrotask(() => { + if (!active) return; + + if (owner) { + runWithOwner(owner, () => { + void fn(); + }); + } else { + void fn(); + } + }); + }); +} + +export function onDeferred( + fn: () => void | Promise, + options: DeferredOptions | number = 250, +): () => void { + if (isServer) return () => {}; + + const config: DeferredOptions = + typeof options === "number" ? { delayMs: options } : options; + + const delayMs = config.delayMs ?? 250; + const useIdle = config.idle ?? false; + const idleTimeout = config.idleTimeout ?? 1000; + const owner: Owner | null = getOwner(); + + let handle: number | ReturnType | null = null; + let active = true; + + const cancel = (): void => { + active = false; + if (handle !== null) { + if (useIdle && typeof window !== "undefined" && "cancelIdleCallback" in window) { + window.cancelIdleCallback(handle as number); + } else { + clearTimeout(handle as ReturnType); + } + handle = null; + } + }; + + onMount(() => { + onCleanup(cancel); + + const execute = (): void => { + if (!active) return; + handle = null; + + if (owner) { + runWithOwner(owner, () => { + void fn(); + }); + } else { + void fn(); + } + }; + + if (useIdle && typeof window !== "undefined" && "requestIdleCallback" in window) { + handle = window.requestIdleCallback(execute, { timeout: idleTimeout }); + } else { + handle = setTimeout(execute, delayMs); + } + }); + + return cancel; +} diff --git a/packages/lifecycle/test/index.test.ts b/packages/lifecycle/test/index.test.ts index 20098018c..40fb394dd 100644 --- a/packages/lifecycle/test/index.test.ts +++ b/packages/lifecycle/test/index.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect } from "vitest"; -import { createEffect, createRoot } from "solid-js"; -import { createIsMounted, isHydrated } from "../src/index.js"; +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEffect, createRoot, createSignal } from "solid-js"; +import { createIsMounted, isHydrated, onInitialRender, onDeferred } from "../src/index.js"; describe("createIsMounted", () => { test("createIsMounted", () => { @@ -23,3 +23,111 @@ describe("isHydrated", () => { expect(isHydrated()).toBe(true); }); }); + +describe("onInitialRender", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("executes callback asynchronously in the next microtask after mount", async () => { + const fn = vi.fn(); + + createRoot(dispose => { + onInitialRender(fn); + expect(fn).not.toHaveBeenCalled(); + dispose(); + }); + + await vi.runAllTicksAsync(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("preserves reactive signal scope inside the owner hierarchy", async () => { + let capturedValue = ""; + + createRoot(dispose => { + const [name] = createSignal("SolidJS"); + + onInitialRender(() => { + capturedValue = name(); + }); + + dispose(); + }); + + await vi.runAllTicksAsync(); + expect(capturedValue).toBe("SolidJS"); + }); + + test("does not execute if component is unmounted prior to microtask execution", async () => { + const fn = vi.fn(); + + const dispose = createRoot(disposeFn => { + onInitialRender(fn); + return disposeFn; + }); + + dispose(); + await vi.runAllTicksAsync(); + + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe("onDeferred", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("executes callback after specified delay", () => { + const fn = vi.fn(); + + createRoot(dispose => { + onDeferred(fn, 500); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(499); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledTimes(1); + + dispose(); + }); + }); + + test("cancels execution if disposed before delay expires", () => { + const fn = vi.fn(); + + createRoot(dispose => { + onDeferred(fn, 500); + vi.advanceTimersByTime(200); + dispose(); + }); + + vi.advanceTimersByTime(400); + expect(fn).not.toHaveBeenCalled(); + }); + + test("supports manual cancellation via returned handle", () => { + const fn = vi.fn(); + + createRoot(dispose => { + const cancel = onDeferred(fn, 300); + vi.advanceTimersByTime(100); + cancel(); + vi.advanceTimersByTime(300); + expect(fn).not.toHaveBeenCalled(); + dispose(); + }); + }); +});