diff --git a/.changeset/a11y-dataset-hydration-safety.md b/.changeset/a11y-dataset-hydration-safety.md new file mode 100644 index 000000000..9265de3b0 --- /dev/null +++ b/.changeset/a11y-dataset-hydration-safety.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/a11y": patch +--- + +`createFormControl`'s `dataset` accessor is now a plain getter instead of a `createMemo` — no behavior or type change, but it removes an unnecessary render-body compute-form memo (memoizing a handful of cheap string/undefined fields buys nothing) that would otherwise consume a hydration id in every consuming app. diff --git a/.changeset/controlled-props-void-component-types.md b/.changeset/controlled-props-void-component-types.md new file mode 100644 index 000000000..875226ce4 --- /dev/null +++ b/.changeset/controlled-props-void-component-types.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/controlled-props": patch +--- + +`BoolProp`, `NumberProp`, `RangeProp`, and `StringProp` are now typed `VoidComponent` instead of `Component`, since none of them accept or render children. Type-only change, no behavior difference. diff --git a/.changeset/focus-restore-primitive.md b/.changeset/focus-restore-primitive.md new file mode 100644 index 000000000..9c434ecb6 --- /dev/null +++ b/.changeset/focus-restore-primitive.md @@ -0,0 +1,7 @@ +--- +"@solid-primitives/focus": minor +--- + +Add `createFocusRestore` — saves the currently focused element while active and restores focus to it once deactivated, without trapping focus or managing tab order. For non-modal surfaces (Popover, Tooltip, Menu) that should return focus to their trigger on close but must not intercept Tab navigation while open; for modal dialogs needing both behaviors, use `createFocusTrap`'s existing `restoreFocus` option instead. + +Also fixed a stale-closure race shared between `createFocusTrap` and the new `createFocusRestore`: the restore target used to be read from a mutable outer variable inside a deferred `afterPaint` callback, so a fast reactivate-before-restore-fires cycle could restore focus to the wrong element. Both primitives now share one internal helper that captures the target synchronously at deactivation time. diff --git a/.changeset/interaction-inert-option.md b/.changeset/interaction-inert-option.md new file mode 100644 index 000000000..d38276152 --- /dev/null +++ b/.changeset/interaction-inert-option.md @@ -0,0 +1,7 @@ +--- +"@solid-primitives/interaction": minor +--- + +`createHideOutside`/`ariaHideOutside` gain an opt-in `inert` option — in addition to `aria-hidden`, also sets the `inert` attribute on hidden elements, ref-counted alongside `aria-hidden`. `aria-hidden` alone only affects the accessibility tree; `inert` additionally removes hidden content from focus order, tab order, and pointer/text-selection interaction. Defaults to `false` (no behavior change for existing callers). + +Also hardened the module-scope ref-counting/observer-stack state (used internally by both the existing `aria-hidden` tracking and the new `inert` tracking) against duplicate installed copies of this package, via the same `globalRegistry` pattern used in `@solid-primitives/scroll`. diff --git a/.changeset/intersection-observer-notreadyerror-fix.md b/.changeset/intersection-observer-notreadyerror-fix.md new file mode 100644 index 000000000..39c8bed9b --- /dev/null +++ b/.changeset/intersection-observer-notreadyerror-fix.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/intersection-observer": patch +--- + +Fix `isVisible()`/`createVisibilityObserver()`'s `visible()` throwing a locally-defined `NotReadyError` lookalike class instead of the real one exported from `solid-js`. Because the local class wasn't `instanceof` the real `NotReadyError`, `` boundaries never actually caught it — despite the documented `` integration, calling `isVisible`/`visible` before the first observation would crash rendering instead of showing the Loading fallback. `NotReadyError` is now imported and re-exported from `solid-js` directly, so `` (and any `instanceof` check) works as documented. diff --git a/.changeset/notification-affects-pending.md b/.changeset/notification-affects-pending.md new file mode 100644 index 000000000..a9dc91b82 --- /dev/null +++ b/.changeset/notification-affects-pending.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/notification": patch +--- + +`createNotificationPermission`'s `requestPermission` now calls `affects(permission)`, so `isPending(permission)` reads `true` for the duration of the request — the standard Solid 2.0 idiom for callers who don't want the existing bespoke `pending` accessor, which is unchanged and kept for backward compatibility. diff --git a/.changeset/scroll-duplicate-copy-safety.md b/.changeset/scroll-duplicate-copy-safety.md new file mode 100644 index 000000000..7abb1643b --- /dev/null +++ b/.changeset/scroll-duplicate-copy-safety.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/scroll": patch +--- + +`createPreventScroll`'s active-instance stack and body-style ref-counts now live in a `globalRegistry` (keyed on `globalThis`, not module-scope bindings), so they stay correct even if the app's dependency graph ends up with more than one copy of this package installed — module-scope state would otherwise be split across copies, breaking the "topmost instance" ref-counting. Also replaced a hand-rolled `contains()` helper with the equivalent one already exported from `@solid-primitives/utils`. No API changes. diff --git a/.changeset/utils-global-registry.md b/.changeset/utils-global-registry.md new file mode 100644 index 000000000..7f63fb9d3 --- /dev/null +++ b/.changeset/utils-global-registry.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/utils": minor +--- + +Add `globalRegistry(key, init)` — returns a singleton value keyed by `key` on `globalThis` (via `Symbol.for`), shared across every copy of the calling module loaded in the same JS realm. Use it instead of a plain module-scope `let`/`const` for state (ref-counts, active-instance stacks) that must stay consistent even if the app's dependency graph ends up with more than one installed copy of a package. diff --git a/.gitignore b/.gitignore index 82c77b3bc..b8260cf90 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ pages # temp _temp_* +.hydration-harness-* # Local Netlify folder .netlify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8bc7c92c..ede83dc22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,19 @@ Solid uses the `create` prefix to define a primitive that provides reactive util Solid Primitives is mostly about supplying 80-90% of the common-use cases for the end-user. We prefer to be less prescriptive than other hook libraries such as VueUse and supply granular solutions as opposed to monolithic primitives. The remaining 10-20% of complex use cases are likely not to be covered with this library. This is on purpose to limit the potential of bloat and extended complexity. This project strives to provide foundations and not cumulative solutions. We expect the broader ecosystem will fill the remaining need as further composition to this projects effort. This allows for just the right amount of prescription and opinion. +### Hydration Safety + +A primitive that creates a **compute-form** `createSignal(fn)` or `createMemo(fn)` in a component's render body participates in Solid's per-owner hydration id allocation, the same as a DOM element the JSX compiler stamps a hydration key onto. Most primitives that return a derived reactive value (`createPagination`, `createHideOutside`'s internals, anything backed by `createMemo`) fall into this category — it's the norm, not the exception, and on its own isn't a defect. Classify each package's primitives as one of: + +- **Effect-only** — no render-body `createSignal(fn)`/`createMemo`, only `createEffect`/`createSignal(value)`/plain functions. Always hydration-safe. +- **Creates a render-body compute signal/memo** — returns state derived via `createSignal(fn)` or `createMemo`. Verify it with a real hydration round trip (see below) rather than assuming it's fine. + +Prefer a plain (non-memoized) getter function over `createMemo` when the derived value is cheap to recompute (a handful of object fields, a short string) — it avoids consuming a hydration id for no measurable perf benefit. Reach for `createMemo` when the computation is genuinely non-trivial. + +Use [`scripts/test-utils/hydration-harness.ts`](scripts/test-utils/hydration-harness.ts)'s `renderHydrationRoundTrip()` to verify a package end-to-end: it renders a small fixture component through a real `node` subprocess (so `@solid-primitives/*` and `@solidjs/web` resolve to their published `dist` builds, exactly as an installed consumer gets them — not this monorepo's `@solid-primitives/source` workspace alias that regular `test/server.test.ts` files resolve through), then hydrates the result in-process and asserts no console error/warning. See `packages/controlled-signal/test/hydration.test.tsx` and `packages/a11y/test/hydration.test.tsx` for examples. Add one of these for any primitive whose public API returns a `createMemo`/compute-form-`createSignal` value that a consumer is expected to read directly in a render body. + +**Rebuild before trusting a result.** The harness's server half resolves the target package's published `dist`, but its client half resolves workspace `src` (through this monorepo's own alias). If you edit a package's `src` without rebuilding `dist`, the two halves are running genuinely different code, which can desync owner-id allocation for real and produce a false-positive "unclaimed server-rendered node" warning that has nothing to do with the hazard under test. Run `pnpm -w build` (or scope it to the packages you touched) before running a hydration test that depends on your changes. + ## NPM Release and Repository Structure Solid Primitives is a large and growing project and the way we manage and release updates has been setup to suit the projects scope. The approach we've taken with organizing our packages and npm releases is more granular than other projects such as VueUse which ship all updates in a single release. diff --git a/packages/a11y/src/form-control.ts b/packages/a11y/src/form-control.ts index d58607bb3..b5a7a73e4 100644 --- a/packages/a11y/src/form-control.ts +++ b/packages/a11y/src/form-control.ts @@ -16,7 +16,6 @@ import type { Context } from "solid-js"; import { createContext, createEffect, - createMemo, createSignal, createUniqueId, useContext, @@ -140,13 +139,16 @@ export function createFormControl(props: CreateFormControlProps): FormControlCon ); }; - const dataset = createMemo(() => ({ + // Plain getter, not createMemo: the object is cheap to rebuild on every read, and a + // render-body compute-form memo would consume a hydration id in every consuming app — + // see the transform-boundary hydration hazard this pattern is designed to avoid. + const dataset = (): FormControlDataSet => ({ "data-valid": access(props.validationState) === "valid" ? "" : undefined, "data-invalid": access(props.validationState) === "invalid" ? "" : undefined, "data-required": access(props.required) ? "" : undefined, "data-disabled": access(props.disabled) ? "" : undefined, "data-readonly": access(props.readOnly) ? "" : undefined, - })); + }); return { name: () => access(props.name) ?? id(), diff --git a/packages/a11y/stories/a11y.stories.tsx b/packages/a11y/stories/a11y.stories.tsx index f871846d3..c9907e126 100644 --- a/packages/a11y/stories/a11y.stories.tsx +++ b/packages/a11y/stories/a11y.stories.tsx @@ -27,8 +27,6 @@ import { radii, } from "../../../.storybook/ui/index.js"; -// ─── Local helpers ──────────────────────────────────────────────────────────── - const MonoValue = (props: { children: Element }) => ( ); -// ─── Meta ───────────────────────────────────────────────────────────────────── - const meta = preview.meta({ title: "Inputs/a11y", tags: ["autodocs"], @@ -108,8 +104,6 @@ const meta = preview.meta({ export default meta; -// ─── createAnnounce ─────────────────────────────────────────────────────────── - export const Announce = meta.story({ name: "Screen reader announcements", parameters: { @@ -207,8 +201,6 @@ export const Announce = meta.story({ }, }); -// ─── createReducedMotion ────────────────────────────────────────────────────── - export const ReducedMotion = meta.story({ name: "Reduced motion preference", parameters: { @@ -283,8 +275,6 @@ export const ReducedMotion = meta.story({ }, }); -// ─── Accessible field (raw API) ─────────────────────────────────────────────── - export const StandaloneField = meta.story({ name: "Accessible field", parameters: { @@ -373,8 +363,6 @@ export const StandaloneField = meta.story({ }, }); -// ─── Sub-component pattern ──────────────────────────────────────────────────── - export const SubComponentPattern = meta.story({ name: "Sub-component pattern", parameters: { @@ -416,8 +404,6 @@ export const SubComponentPattern = meta.story({ }, }); -// ─── Context provider pattern ───────────────────────────────────────────────── - export const ContextProviderPattern = meta.story({ name: "Context provider pattern", parameters: { @@ -433,8 +419,6 @@ export const ContextProviderPattern = meta.story({ undefined, ); - // ── Sub-components built inline to show the raw wiring ─────────────────── - const Label = (props: { children: Element }) => { const ctx = useFormControl(); const id = ctx.generateId("label"); @@ -497,8 +481,6 @@ export const ContextProviderPattern = meta.story({ ); }; - // ── Root: creates context, provides it ─────────────────────────────────── - const ctx = createFormControl({ id: "ctx-demo", validationState, required: true }); return ( @@ -529,8 +511,6 @@ export const ContextProviderPattern = meta.story({ }, }); -// ─── Validation states ──────────────────────────────────────────────────────── - export const ValidationStates = meta.story({ name: "Validation states", parameters: { @@ -594,8 +574,6 @@ export const ValidationStates = meta.story({ }, }); -// ─── aria-labelledby chain ──────────────────────────────────────────────────── - export const AriaLabelledByChain = meta.story({ name: "Label chain resolution", parameters: { diff --git a/packages/a11y/test/hydration.test.tsx b/packages/a11y/test/hydration.test.tsx new file mode 100644 index 000000000..5e9392dba --- /dev/null +++ b/packages/a11y/test/hydration.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + renderHydrationRoundTrip, + type HydrationRoundTripResult, +} from "../../../scripts/test-utils/hydration-harness.ts"; + +// createFormControl backs its labelId/fieldId/descriptionId/errorMessageId with +// createSignal(undefined, { ownedWrite: true }) internal signals created in the render body — +// the same class of construct hope-ui's solid-primitives-eval.md flags as a hydration-id hazard. +// Renders through the real, standard @solidjs/web renderToString + hydrate pipeline (not our own +// SSR harness) with the package resolved from its published `dist` build. +const APP_SOURCE = ` +import { createFormControl, FormControlContext } from "@solid-primitives/a11y"; + +export default function App() { + const ctx = createFormControl({ id: "email", required: true }); + ctx.registerLabel("email-label"); + + return ( + +
+ + +
+
+ ); +} +`; + +describe("createFormControl hydration round trip", () => { + let result: HydrationRoundTripResult | undefined; + + afterEach(() => { + result?.cleanup(); + result = undefined; + }); + + it("server-renders the required dataset attribute", async () => { + result = await renderHydrationRoundTrip(APP_SOURCE, import.meta.dirname); + expect(result.html).toContain('data-required=""'); + }); + + it("hydrates without any console error or warning", async () => { + result = await renderHydrationRoundTrip(APP_SOURCE, import.meta.dirname); + expect(result.consoleMessages).toEqual([]); + }); + + it("hydrates the DOM with the correct aria attributes", async () => { + result = await renderHydrationRoundTrip(APP_SOURCE, import.meta.dirname); + const input = result.container.querySelector("#email-input"); + expect(input?.getAttribute("aria-labelledby")).toBe("email-label"); + expect(input?.getAttribute("data-required")).toBe(""); + }); +}); diff --git a/packages/a11y/test/index.test.tsx b/packages/a11y/test/index.test.tsx index 88ebeab79..a1d16580a 100644 --- a/packages/a11y/test/index.test.tsx +++ b/packages/a11y/test/index.test.tsx @@ -12,8 +12,6 @@ import { createReducedMotion, } from "../src/index.js"; -// ─── createFormControl ──────────────────────────────────────────────────────── - describe("createFormControl", () => { it("auto-generates a stable id when none provided", () => { createRoot(dispose => { @@ -73,8 +71,6 @@ describe("createFormControl", () => { }); }); - // ─── dataset ─────────────────────────────────────────────────────────────── - it("dataset: undefined validationState sets no data attributes", () => { createRoot(dispose => { const ctx = createFormControl({}); @@ -147,8 +143,6 @@ describe("createFormControl", () => { dispose(); }); - // ─── state accessors ─────────────────────────────────────────────────────── - it("state accessors reflect props reactively", () => { const [required, setRequired] = createSignal(false); const [disabled, setDisabled] = createSignal(false); @@ -167,8 +161,6 @@ describe("createFormControl", () => { dispose(); }); - // ─── registration ────────────────────────────────────────────────────────── - it("registerLabel sets labelId and cleanup deregisters", () => { createRoot(dispose => { const ctx = createFormControl({ id: "ctrl" }); @@ -209,8 +201,6 @@ describe("createFormControl", () => { }); }); - // ─── ARIA computation ────────────────────────────────────────────────────── - it("getAriaLabelledBy: returns undefined with no label registered", () => { createRoot(dispose => { const ctx = createFormControl({ id: "ctrl" }); @@ -310,8 +300,6 @@ describe("createFormControl", () => { }); }); -// ─── createFormControlInput ─────────────────────────────────────────────────── - describe("createFormControlInput", () => { function withControl( ctxId: string, @@ -448,8 +436,6 @@ describe("createFormControlInput", () => { }); }); -// ─── useFormControl ─────────────────────────────────────────────────────────── - describe("useFormControl", () => { it("throws when called outside a FormControlContext", () => { createRoot(dispose => { @@ -479,8 +465,6 @@ describe("useFormControl", () => { }); }); -// ─── makeAnnounce / createAnnounce ──────────────────────────────────────────── - describe("makeAnnounce", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => { @@ -575,8 +559,6 @@ describe("createAnnounce", () => { }); }); -// ─── createReducedMotion ────────────────────────────────────────────────────── - describe("createReducedMotion", () => { let changeListeners: Array<(e: MediaQueryListEvent) => void> = []; let mqMatches = false; diff --git a/packages/analytics/test/index.test.ts b/packages/analytics/test/index.test.ts index b70742d47..7efa56aca 100644 --- a/packages/analytics/test/index.test.ts +++ b/packages/analytics/test/index.test.ts @@ -12,8 +12,6 @@ function makeReadyPlugin(overrides: Partial = {}): AnalyticsPlu beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); -// ─── makeAnalytics ──────────────────────────────────────────────────────────── - describe("makeAnalytics", () => { it("dispatches page events immediately to a ready plugin", async () => { const pageSpy = vi.fn(); @@ -177,8 +175,6 @@ describe("makeAnalytics", () => { }); }); -// ─── createAnalytics ────────────────────────────────────────────────────────── - describe("createAnalytics", () => { let dispose: () => void; @@ -246,8 +242,6 @@ describe("createAnalytics", () => { }); }); -// ─── drain ──────────────────────────────────────────────────────────────────── - describe("drain()", () => { it("resolves immediately when no dispatches are in flight", async () => { const [analytics, cleanup] = makeAnalytics([makeReadyPlugin()]); @@ -293,8 +287,6 @@ describe("drain()", () => { }); }); -// ─── drainInterval / drainSize ──────────────────────────────────────────────── - describe("drainInterval / drainSize", () => { it("batches events and dispatches on the drain interval", async () => { const spy = vi.fn(); @@ -364,8 +356,6 @@ describe("drainInterval / drainSize", () => { }); }); -// ─── makeAnalyticsGuard ─────────────────────────────────────────────────────── - describe("makeAnalyticsGuard", () => { it("onBeforeLeave prevents default and retries after drain", async () => { let resolveTrack!: () => void; @@ -437,8 +427,6 @@ describe("makeAnalyticsGuard", () => { }); }); -// ─── createAnalyticsGuard ───────────────────────────────────────────────────── - describe("createAnalyticsGuard", () => { let dispose: () => void; afterEach(() => dispose?.()); diff --git a/packages/analytics/test/server.test.ts b/packages/analytics/test/server.test.ts index 05d5e32e4..23850897e 100644 --- a/packages/analytics/test/server.test.ts +++ b/packages/analytics/test/server.test.ts @@ -10,8 +10,6 @@ function delay(ms = 0): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -// ─── makeAnalytics (server) ─────────────────────────────────────────────────── - describe("makeAnalytics (server)", () => { it("dispatches page events to a synchronous plugin", async () => { const spy = vi.fn(); @@ -135,8 +133,6 @@ describe("makeAnalytics (server)", () => { }); }); -// ─── createAnalytics (server) ───────────────────────────────────────────────── - describe("createAnalytics (server)", () => { it("dispatches events inside a createRoot scope on the server", async () => { const spy = vi.fn(); diff --git a/packages/audio/test/index.test.ts b/packages/audio/test/index.test.ts index 240c1953f..99995fc8d 100644 --- a/packages/audio/test/index.test.ts +++ b/packages/audio/test/index.test.ts @@ -9,8 +9,6 @@ const testPath = /** Yield to the microtask queue — used alongside flush() to drain Solid 2.0 effects. */ const tick = () => Promise.resolve(); -// ── makeAudio ───────────────────────────────────────────────────────────────── - describe("makeAudio", () => { it("returns a player and cleanup tuple", () => { const [player, cleanup] = makeAudio(testPath); @@ -48,8 +46,6 @@ describe("makeAudio", () => { }); }); -// ── makeAudioPlayer ─────────────────────────────────────────────────────────── - describe("makeAudioPlayer", () => { it("returns controls and cleanup tuple", () => { const [controls, cleanup] = makeAudioPlayer(testPath); @@ -82,8 +78,6 @@ describe("makeAudioPlayer", () => { }); }); -// ── createAudio ─────────────────────────────────────────────────────────────── - describe("createAudio", () => { it("returns flat object with expected shape", () => createRoot(dispose => { diff --git a/packages/controlled-props/src/index.tsx b/packages/controlled-props/src/index.tsx index 16adaa177..1ba434160 100644 --- a/packages/controlled-props/src/index.tsx +++ b/packages/controlled-props/src/index.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal, For } from "solid-js"; -import type { Accessor, Component, Setter } from "solid-js"; +import type { Accessor, Component, Setter, VoidComponent } from "solid-js"; import type { JSX } from "@solidjs/web"; import { INTERNAL_OPTIONS } from "@solid-primitives/utils"; @@ -28,7 +28,7 @@ export type TestPropProps = { step?: T extends number ? number : undefined; }; -export const BoolProp: Component> = props => ( +export const BoolProp: VoidComponent> = props => ( ); -export const NumberProp: Component> = props => ( +export const NumberProp: VoidComponent> = props => ( ); -export const RangeProp: Component> = props => ( +export const RangeProp: VoidComponent> = props => ( ); -export const StringProp: Component> = props => ( +export const StringProp: VoidComponent> = props => (