Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/a11y-dataset-hydration-safety.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/controlled-props-void-component-types.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/focus-restore-primitive.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/interaction-inert-option.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/intersection-observer-notreadyerror-fix.md
Original file line number Diff line number Diff line change
@@ -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`, `<Loading>` boundaries never actually caught it — despite the documented `<Loading>` 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 `<Loading>` (and any `instanceof` check) works as documented.
5 changes: 5 additions & 0 deletions .changeset/notification-affects-pending.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/scroll-duplicate-copy-safety.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/utils-global-registry.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ pages

# temp
_temp_*
.hydration-harness-*

# Local Netlify folder
.netlify
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions packages/a11y/src/form-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type { Context } from "solid-js";
import {
createContext,
createEffect,
createMemo,
createSignal,
createUniqueId,
useContext,
Expand Down Expand Up @@ -140,13 +139,16 @@ export function createFormControl(props: CreateFormControlProps): FormControlCon
);
};

const dataset = createMemo<FormControlDataSet>(() => ({
// 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(),
Expand Down
22 changes: 0 additions & 22 deletions packages/a11y/stories/a11y.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ import {
radii,
} from "../../../.storybook/ui/index.js";

// ─── Local helpers ────────────────────────────────────────────────────────────

const MonoValue = (props: { children: Element }) => (
<span
style={{
Expand Down Expand Up @@ -91,8 +89,6 @@ const AriaDebug = (props: { entries: { attr: string; value: string | undefined }
</div>
);

// ─── Meta ─────────────────────────────────────────────────────────────────────

const meta = preview.meta({
title: "Inputs/a11y",
tags: ["autodocs"],
Expand All @@ -108,8 +104,6 @@ const meta = preview.meta({

export default meta;

// ─── createAnnounce ───────────────────────────────────────────────────────────

export const Announce = meta.story({
name: "Screen reader announcements",
parameters: {
Expand Down Expand Up @@ -207,8 +201,6 @@ export const Announce = meta.story({
},
});

// ─── createReducedMotion ──────────────────────────────────────────────────────

export const ReducedMotion = meta.story({
name: "Reduced motion preference",
parameters: {
Expand Down Expand Up @@ -283,8 +275,6 @@ export const ReducedMotion = meta.story({
},
});

// ─── Accessible field (raw API) ───────────────────────────────────────────────

export const StandaloneField = meta.story({
name: "Accessible field",
parameters: {
Expand Down Expand Up @@ -373,8 +363,6 @@ export const StandaloneField = meta.story({
},
});

// ─── Sub-component pattern ────────────────────────────────────────────────────

export const SubComponentPattern = meta.story({
name: "Sub-component pattern",
parameters: {
Expand Down Expand Up @@ -416,8 +404,6 @@ export const SubComponentPattern = meta.story({
},
});

// ─── Context provider pattern ─────────────────────────────────────────────────

export const ContextProviderPattern = meta.story({
name: "Context provider pattern",
parameters: {
Expand All @@ -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");
Expand Down Expand Up @@ -497,8 +481,6 @@ export const ContextProviderPattern = meta.story({
);
};

// ── Root: creates context, provides it ───────────────────────────────────

const ctx = createFormControl({ id: "ctx-demo", validationState, required: true });

return (
Expand Down Expand Up @@ -529,8 +511,6 @@ export const ContextProviderPattern = meta.story({
},
});

// ─── Validation states ────────────────────────────────────────────────────────

export const ValidationStates = meta.story({
name: "Validation states",
parameters: {
Expand Down Expand Up @@ -594,8 +574,6 @@ export const ValidationStates = meta.story({
},
});

// ─── aria-labelledby chain ────────────────────────────────────────────────────

export const AriaLabelledByChain = meta.story({
name: "Label chain resolution",
parameters: {
Expand Down
59 changes: 59 additions & 0 deletions packages/a11y/test/hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<FormControlContext value={ctx}>
<div id="out">
<label id="email-label">Email</label>
<input
id="email-input"
aria-labelledby={ctx.getAriaLabelledBy("email-input", undefined, undefined)}
aria-describedby={ctx.getAriaDescribedBy(undefined)}
data-required={ctx.dataset()["data-required"]}
/>
</div>
</FormControlContext>
);
}
`;

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("");
});
});
18 changes: 0 additions & 18 deletions packages/a11y/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import {
createReducedMotion,
} from "../src/index.js";

// ─── createFormControl ────────────────────────────────────────────────────────

describe("createFormControl", () => {
it("auto-generates a stable id when none provided", () => {
createRoot(dispose => {
Expand Down Expand Up @@ -73,8 +71,6 @@ describe("createFormControl", () => {
});
});

// ─── dataset ───────────────────────────────────────────────────────────────

it("dataset: undefined validationState sets no data attributes", () => {
createRoot(dispose => {
const ctx = createFormControl({});
Expand Down Expand Up @@ -147,8 +143,6 @@ describe("createFormControl", () => {
dispose();
});

// ─── state accessors ───────────────────────────────────────────────────────

it("state accessors reflect props reactively", () => {
const [required, setRequired] = createSignal<boolean | undefined>(false);
const [disabled, setDisabled] = createSignal<boolean | undefined>(false);
Expand All @@ -167,8 +161,6 @@ describe("createFormControl", () => {
dispose();
});

// ─── registration ──────────────────────────────────────────────────────────

it("registerLabel sets labelId and cleanup deregisters", () => {
createRoot(dispose => {
const ctx = createFormControl({ id: "ctrl" });
Expand Down Expand Up @@ -209,8 +201,6 @@ describe("createFormControl", () => {
});
});

// ─── ARIA computation ──────────────────────────────────────────────────────

it("getAriaLabelledBy: returns undefined with no label registered", () => {
createRoot(dispose => {
const ctx = createFormControl({ id: "ctrl" });
Expand Down Expand Up @@ -310,8 +300,6 @@ describe("createFormControl", () => {
});
});

// ─── createFormControlInput ───────────────────────────────────────────────────

describe("createFormControlInput", () => {
function withControl(
ctxId: string,
Expand Down Expand Up @@ -448,8 +436,6 @@ describe("createFormControlInput", () => {
});
});

// ─── useFormControl ───────────────────────────────────────────────────────────

describe("useFormControl", () => {
it("throws when called outside a FormControlContext", () => {
createRoot(dispose => {
Expand Down Expand Up @@ -479,8 +465,6 @@ describe("useFormControl", () => {
});
});

// ─── makeAnnounce / createAnnounce ────────────────────────────────────────────

describe("makeAnnounce", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
Expand Down Expand Up @@ -575,8 +559,6 @@ describe("createAnnounce", () => {
});
});

// ─── createReducedMotion ──────────────────────────────────────────────────────

describe("createReducedMotion", () => {
let changeListeners: Array<(e: MediaQueryListEvent) => void> = [];
let mqMatches = false;
Expand Down
Loading
Loading