diff --git a/.changeset/url-initial.md b/.changeset/url-initial.md
new file mode 100644
index 000000000..10f15646d
--- /dev/null
+++ b/.changeset/url-initial.md
@@ -0,0 +1,5 @@
+---
+"@solid-primitives/url": minor
+---
+
+Initial release targeting Solid.js v2.0 (beta.15). Reactive primitives for the Browser's `Location`, `URL`, and `URLSearchParams` interfaces — `createLocationState`/`useSharedLocationState`/`updateLocation`, `createURL`/`ReactiveURL`/`createURLRecord`, and `createSearchParams`/`ReactiveSearchParams`/`createLocationSearchParams`/`useSharedLocationSearchParams`/`getSearchParamsRecord`. Adapted from the design proposed in [PR #77](https://github.com/solidjs-community/solid-primitives/pull/77), rewritten for Solid 2.0 (`createStaticStore` instead of a hand-rolled shallow store, `@solid-primitives/trigger`'s `TriggerCache` for `ReactiveSearchParams`' granular tracking, a projection `createStore(fn, seed)` for `SearchParamsRecord`, and a guarded imperative two-way sync between `ReactiveURL.search` and `.searchParams` instead of `createComputed`/`on`, both removed in 2.0).
diff --git a/README.md b/README.md
index 5dc76a818..dab6833d4 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](https://pnpm.io/)
[](https://vitest.dev)
-[](https://dash.deno.com/playground/combined-npm-downloads)
+[](https://dash.deno.com/playground/combined-npm-downloads)
Solid Primitives is a project dedicated to building high-quality, community-contributed primitives for Solid 2.0. Every utility is thoroughly tested, continuously maintained, and reviewed against a consistent quality bar before it lands in the repository. Our aim is to extend Solid's primary and secondary primitives with a well-rounded set of tertiary primitives.
@@ -51,7 +51,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n
|[scroll](https://github.com/solidjs-community/solid-primitives/tree/main/packages/scroll#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createScrollPosition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/scroll#createscrollposition) [useWindowScrollPosition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/scroll#usewindowscrollposition) [createPreventScroll](https://github.com/solidjs-community/solid-primitives/tree/main/packages/scroll#createpreventscroll)|[](https://bundlephobia.com/package/@solid-primitives/scroll)|[](https://www.npmjs.com/package/@solid-primitives/scroll)|✓|
|[selection](https://github.com/solidjs-community/solid-primitives/tree/main/packages/selection#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSelection](https://github.com/solidjs-community/solid-primitives/tree/main/packages/selection#createselection)|[](https://bundlephobia.com/package/@solid-primitives/selection)|[](https://www.npmjs.com/package/@solid-primitives/selection)|✓|
|
|
|[connectivity](https://github.com/solidjs-community/solid-primitives/tree/main/packages/connectivity#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createConnectivitySignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/connectivity#createconnectivitysignal)|[](https://bundlephobia.com/package/@solid-primitives/connectivity)|[](https://www.npmjs.com/package/@solid-primitives/connectivity)|✓|
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
index 480768414..09cbf1d14 100644
--- a/packages/analytics/package.json
+++ b/packages/analytics/package.json
@@ -15,7 +15,8 @@
"createAnalyticsGuard",
"createServerPlugin"
],
- "category": "Utilities"
+ "category": "Utilities",
+ "gzip": 1863
},
"license": "MIT",
"homepage": "https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#readme",
diff --git a/packages/url/LICENSE b/packages/url/LICENSE
new file mode 100644
index 000000000..ba8f69c3d
--- /dev/null
+++ b/packages/url/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/url/README.md b/packages/url/README.md
new file mode 100644
index 000000000..d48b353fe
--- /dev/null
+++ b/packages/url/README.md
@@ -0,0 +1,240 @@
+
+
+
+
+# @solid-primitives/url
+
+[](https://lerna.js.org/)
+[](https://www.npmjs.com/package/@solid-primitives/url)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+[](https://vitest.dev)
+
+Reactive primitives for the Browser's `Location`, `URL`, and `URLSearchParams` interfaces.
+
+> **Already using [Solid Router](https://docs.solidjs.com/solid-router)?** It already covers a lot of the same ground — reactive location, search params, and navigation — and is the better choice if you need routing anyway. These primitives are for when you want that reactivity without pulling in a router, e.g. in a non-routed app or a library.
+
+- [`createLocationState`](#createlocationstate) — Reactive `window.location` state, with `push`/`replace`/`navigate` setters.
+- [`useSharedLocationState`](#usesharedlocationstate) — Shared-root version of `createLocationState`.
+- [`updateLocation`](#updatelocation) — Non-reactive helper to push, replace, or navigate to a new href.
+- [`createURL`](#createurl) / [`ReactiveURL`](#reactiveurl) — A reactive `URL`-like object.
+- [`createURLRecord`](#createurlrecord) — A `URL` instance as a reactive store.
+- [`createSearchParams`](#createsearchparams) / [`ReactiveSearchParams`](#reactivesearchparams) — A reactive `URLSearchParams`-like object.
+- [`createLocationSearchParams`](#createlocationsearchparams) — Reactive record of `window.location.search`.
+
+## Installation
+
+```bash
+npm install @solid-primitives/url
+# or
+pnpm add @solid-primitives/url
+# or
+yarn add @solid-primitives/url
+```
+
+## `createLocationState`
+
+Creates a reactive `window.location` state. The state updates whenever the location changes —
+via back/forward navigation, a `#hash` change, or `history.pushState`/`replaceState` — and can be
+modified using the provided setter methods.
+
+```ts
+import { createLocationState } from "@solid-primitives/url";
+
+const [state, { push }] = createLocationState();
+state.href; // => "http://example.com/"
+push({ hash: "heading1" });
+state.href; // => "http://example.com/#heading1"
+```
+
+### Setter methods
+
+`createLocationState` provides three setter methods for updating the `window.location` state.
+All have the same interface, but cause different side effects:
+
+- `push` — uses `history.pushState`
+- `replace` — uses `history.replaceState`
+- `navigate` — overwrites `location.href`, forcing a full navigation
+
+Each setter accepts either a partial record (or an updater function returning one), or a single
+`(key, value)` pair:
+
+```ts
+push({ pathname: "/other", hash: "top" });
+push(prev => ({ search: prev.search + "&page=2" }));
+push("hash", "top");
+```
+
+### Server fallback
+
+`location` isn't available on the server, so a fallback has to be provided — either per-call, or
+globally with `setLocationFallback` (call it before the primitives that rely on it):
+
+```ts
+// fallback can be a href string, a URL instance, or a LocationState object
+const [state] = createLocationState("http://example.com");
+
+// or globally, once, before rendering:
+setLocationFallback("http://example.com");
+const [state] = createLocationState();
+```
+
+## `useSharedLocationState`
+
+Reuses a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot)
+`createLocationState`, or creates one if one isn't active yet. Use it instead of
+`createLocationState` to avoid recreating signals, computations, and event listeners for every
+consumer. Its interface is the same, minus the `fallback` argument — use `setLocationFallback`
+instead.
+
+```ts
+const [state, { push }] = useSharedLocationState();
+```
+
+## `updateLocation`
+
+A non-reactive utility to push, replace, or navigate to a new href.
+
+```ts
+updateLocation("http://example.com?foo=bar", "push");
+```
+
+## `createURL`
+
+Creates an instance of [`ReactiveURL`](#reactiveurl).
+
+```ts
+import { createURL } from "@solid-primitives/url";
+
+const url = createURL("http://example.com");
+url.host; // => "example.com"
+url.search = "?foo=bar";
+createEffect(() => console.log(url.href));
+```
+
+## `ReactiveURL`
+
+An object providing reactive setters and getters for managing a URL — same interface as the
+native `URL` class, but every getter is granularly reactive, causing updates only when its value
+has actually changed.
+
+`ReactiveURL` does **not** extend `URL`, so `x instanceof URL` won't hold. As a reactive
+structure, it should be instantiated under a reactive root (e.g. inside `createRoot` or a
+component).
+
+```ts
+const url = new ReactiveURL("http://example.com");
+url.host; // => "example.com"
+url.search = "?foo=bar";
+url.hash = "heading1";
+createEffect(() => console.log(url.href));
+```
+
+### `searchParams`
+
+Same as the native `URL` class, `ReactiveURL` provides a `searchParams` getter, returning an
+instance of [`ReactiveSearchParams`](#reactivesearchparams) kept in sync with `url.search` in
+both directions. The reference is stable, so it can be destructured freely.
+
+```ts
+const url = new ReactiveURL("http://example.com");
+const { searchParams } = url;
+
+createEffect(() => console.log(searchParams.get("foo")));
+url.search = "?foo=bar"; // reruns the effect above
+searchParams.set("foo", "baz"); // updates url.search to "?foo=baz"
+```
+
+## `createURLRecord`
+
+Provides a `URL` instance as a reactive store.
+
+```ts
+import { createURLRecord } from "@solid-primitives/url";
+
+const [url, setURL] = createURLRecord("http://example.com");
+url.host; // => "example.com"
+setURL({ hash: "heading1" });
+url.hash; // => "#heading1"
+```
+
+## `createSearchParams`
+
+Creates an instance of [`ReactiveSearchParams`](#reactivesearchparams).
+
+```ts
+const params = createSearchParams("foo=1&foo=2&bar=baz");
+```
+
+## `ReactiveSearchParams`
+
+A reactive version of the `URLSearchParams` class. Every read method is granular — it causes an
+update only when the value it read has actually changed.
+
+`ReactiveSearchParams` extends `URLSearchParams`, so `x instanceof URLSearchParams` holds. As a
+reactive structure, it should be instantiated under a reactive root.
+
+```ts
+const params = new ReactiveSearchParams("foo=1&foo=2&bar=baz");
+createEffect(() => console.log(params.getAll("foo")));
+params.append("foo", "3");
+```
+
+## `createLocationSearchParams`
+
+Provides a reactive record reflecting `window.location.search`, updating whenever the browser's
+search params change, along with setter methods to update them.
+
+```ts
+import { createLocationSearchParams } from "@solid-primitives/url";
+
+const [params, { push }] = createLocationSearchParams();
+params.foo; // T: string | string[] | undefined
+push({ ...params, page: "2" });
+```
+
+Passing `(name, value)` updates a single param, keeping the rest of the query string intact:
+
+```ts
+push("page", "3");
+```
+
+Options:
+
+- `useSharedState` — use the shared-root version of the reactive location state (see
+ [`useSharedLocationState`](#usesharedlocationstate)).
+
+## `useSharedLocationSearchParams`
+
+Reuses a shared-root `createLocationSearchParams`, or creates one if one isn't active.
+
+```ts
+const [params, { push }] = useSharedLocationSearchParams();
+```
+
+## `getSearchParamsRecord`
+
+A non-reactive helper turning a `URLSearchParams` instance (or a search string) into a plain
+record — a single value for names that appear once, an array of values for names that repeat.
+
+```ts
+import { getSearchParamsRecord } from "@solid-primitives/url";
+
+const record = getSearchParamsRecord("?foo=bar");
+record; // => { foo: "bar" }
+```
+
+## Demo
+
+You can play with a live demo [here](https://primitives.solidjs.community/playground/url).
+
+## Changelog
+
+
+Expand Changelog
+
+0.1.0
+
+Rewritten for Solid 2.0. Adapted from the original design proposed in
+[PR #77](https://github.com/solidjs-community/solid-primitives/pull/77).
+
+
diff --git a/packages/url/package.json b/packages/url/package.json
new file mode 100644
index 000000000..ec001538f
--- /dev/null
+++ b/packages/url/package.json
@@ -0,0 +1,84 @@
+{
+ "name": "@solid-primitives/url",
+ "version": "0.1.0",
+ "description": "Reactive primitives covering Browser's location, URL and URLSearchParams interfaces.",
+ "author": "David Di Biase ",
+ "contributors": [
+ "Damian Tarnawski "
+ ],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/url",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "url",
+ "stage": 0,
+ "list": [
+ "createLocationState",
+ "useSharedLocationState",
+ "updateLocation",
+ "setLocationFallback",
+ "createURL",
+ "createURLRecord",
+ "ReactiveURL",
+ "createSearchParams",
+ "createLocationSearchParams",
+ "useSharedLocationSearchParams",
+ "getSearchParamsRecord",
+ "ReactiveSearchParams"
+ ],
+ "category": "Browser APIs",
+ "gzip": 2631
+ },
+ "keywords": [
+ "solid",
+ "primitives",
+ "url",
+ "location",
+ "search-params"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "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"
+ },
+ "dependencies": {
+ "@solid-primitives/event-listener": "workspace:^",
+ "@solid-primitives/rootless": "workspace:^",
+ "@solid-primitives/static-store": "workspace:^",
+ "@solid-primitives/trigger": "workspace:^",
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "peerDependencies": {
+ "@solidjs/web": "^2.0.0-beta.15",
+ "solid-js": "^2.0.0-beta.15"
+ },
+ "devDependencies": {
+ "@solidjs/web": "2.0.0-beta.15",
+ "solid-js": "2.0.0-beta.15"
+ },
+ "typesVersions": {}
+}
diff --git a/packages/url/src/common.ts b/packages/url/src/common.ts
new file mode 100644
index 000000000..8a28cba54
--- /dev/null
+++ b/packages/url/src/common.ts
@@ -0,0 +1,3 @@
+/** A value to write, or a function deriving it from the previous value. */
+export type SetterValue =
+ Next extends Function ? (prev: Prev) => Next : Next | ((prev: Prev) => Next);
diff --git a/packages/url/src/index.ts b/packages/url/src/index.ts
new file mode 100644
index 000000000..01f5c7589
--- /dev/null
+++ b/packages/url/src/index.ts
@@ -0,0 +1,33 @@
+export {
+ type LocationState,
+ type WritableLocationFields,
+ type LocationSetterRecord,
+ type LocationSetter,
+ type UpdateLocationMethod,
+ type LocationFallbackInit,
+ setLocationFallback,
+ createLocationState,
+ useSharedLocationState,
+ updateLocation,
+} from "./location.js";
+
+export {
+ type SearchParamsRecord,
+ type SearchParamsSetter,
+ type ReactiveSearchParamsInit,
+ getSearchParamsRecord,
+ createLocationSearchParams,
+ useSharedLocationSearchParams,
+ createSearchParams,
+ ReactiveSearchParams,
+} from "./searchParams.js";
+
+export {
+ type URLRecord,
+ type WritableURLFields,
+ type URLSetter,
+ type URLSetterRecord,
+ createURLRecord,
+ createURL,
+ ReactiveURL,
+} from "./url.js";
diff --git a/packages/url/src/location.ts b/packages/url/src/location.ts
new file mode 100644
index 000000000..b995701f3
--- /dev/null
+++ b/packages/url/src/location.ts
@@ -0,0 +1,205 @@
+import { makeEventListener } from "@solid-primitives/event-listener";
+import { createHydratableSingletonRoot } from "@solid-primitives/rootless";
+import { createStaticStore } from "@solid-primitives/static-store";
+import { accessWith, entries, noop, tryOnCleanup } from "@solid-primitives/utils";
+import { pick } from "@solid-primitives/utils/immutable";
+import { getRequestEvent, isServer } from "@solidjs/web";
+import type { SetterValue } from "./common.js";
+
+export type LocationState = {
+ readonly hash: string;
+ readonly host: string;
+ readonly hostname: string;
+ readonly href: string;
+ readonly origin: string;
+ readonly pathname: string;
+ readonly port: string;
+ readonly protocol: string;
+ readonly search: string;
+};
+export type WritableLocationFields = Exclude;
+export type LocationSetterRecord = Partial>;
+
+export type LocationSetter = {
+ (record: SetterValue): void;
+ (key: WritableLocationFields, value: SetterValue): void;
+};
+
+export type UpdateLocationMethod = "push" | "replace" | "navigate";
+export type LocationFallbackInit = LocationState | string | URL;
+
+const LOCATION_FIELDS = [
+ "hash",
+ "host",
+ "hostname",
+ "href",
+ "origin",
+ "pathname",
+ "port",
+ "protocol",
+ "search",
+] as const satisfies readonly (keyof LocationState)[];
+
+let locationFallback: LocationState | undefined;
+
+// history.pushState/replaceState don't dispatch any event on their own, so every
+// createLocationState instance needs to know when either was called — not just
+// on back/forward (popstate) or #hash changes — hence the one-time monkey-patch.
+const historyStateListeners = new Set();
+let patchedHistory = false;
+
+function patchHistoryState(): void {
+ if (patchedHistory) return;
+ patchedHistory = true;
+
+ const pushState = history.pushState.bind(history);
+ const replaceState = history.replaceState.bind(history);
+ const notify = () => historyStateListeners.forEach(listener => listener());
+
+ history.pushState = (...args: Parameters) => {
+ pushState(...args);
+ notify();
+ };
+ history.replaceState = (...args: Parameters) => {
+ replaceState(...args);
+ notify();
+ };
+}
+
+function getLocationStateFromFallback(fallback: LocationFallbackInit): LocationState {
+ if (typeof fallback === "string" || fallback instanceof URL) {
+ return pick(new URL(fallback), ...LOCATION_FIELDS);
+ }
+ return fallback;
+}
+
+/**
+ * Changes window's location state, by pushing or replacing a value in the history stack, or by forcing a navigation to a new href.
+ *
+ * @param href new location's href
+ * @param method `"push" | "replace" | "navigate"`
+ * - `"push"` — uses `history.pushState`
+ * - `"replace"` — uses `history.replaceState`
+ * - `"navigate"` — overwrites `location.href`
+ */
+export function updateLocation(href: string, method: UpdateLocationMethod): void {
+ if (isServer) return;
+ if (method === "push") return history.pushState(null, "", href);
+ if (method === "replace") return history.replaceState(null, "", href);
+ location.href = href;
+}
+
+/**
+ * Sets a global server fallback for `window.location`, used by every {@link createLocationState} that doesn't provide its own.
+ *
+ * Needs to be called before the primitives that rely on it, to take effect.
+ */
+export function setLocationFallback(fallback: LocationFallbackInit): void {
+ if (isServer) locationFallback = getLocationStateFromFallback(fallback);
+}
+
+/**
+ * Creates a reactive `window.location` state.
+ *
+ * The state updates whenever the location changes — via back/forward navigation, a `#hash`
+ * change, or `history.pushState`/`replaceState` — and can be modified using the provided
+ * setter methods, to push, replace, or navigate to a new href.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createLocationState
+ * @param fallback state to use during SSR, when `window.location` isn't available. Falls back to the current request's URL (via `getRequestEvent`), then to {@link setLocationFallback}'s value, if not provided.
+ * @returns
+ * ```ts
+ * [state, { push, replace, navigate }]
+ * ```
+ * @example
+ * ```ts
+ * const [state, { push }] = createLocationState();
+ * state.href; // => "http://example.com"
+ * push({ hash: "heading1" });
+ * ```
+ */
+export function createLocationState(fallback?: LocationFallbackInit): [
+ state: LocationState,
+ setters: {
+ push: LocationSetter;
+ replace: LocationSetter;
+ navigate: LocationSetter;
+ },
+] {
+ if (isServer) {
+ let state: LocationState | undefined;
+ if (fallback) {
+ state = getLocationStateFromFallback(fallback);
+ } else {
+ const requestEvent = getRequestEvent();
+ state = requestEvent
+ ? getLocationStateFromFallback(requestEvent.request.url)
+ : locationFallback;
+ }
+ if (!state) {
+ throw new Error(
+ "createLocationState requires a location fallback for server execution. " +
+ "Pass one as an argument, set a global one with setLocationFallback, or " +
+ "run inside a request context that provides one via getRequestEvent.",
+ );
+ }
+ return [state, { push: noop, replace: noop, navigate: noop }];
+ }
+
+ const [state, setState] = createStaticStore(pick(location, ...LOCATION_FIELDS));
+ const updateState = () => setState(pick(location, ...LOCATION_FIELDS));
+
+ makeEventListener(window, "popstate", updateState);
+ makeEventListener(window, "hashchange", updateState);
+ patchHistoryState();
+ historyStateListeners.add(updateState);
+ tryOnCleanup(() => historyStateListeners.delete(updateState));
+
+ const setter = (
+ method: UpdateLocationMethod,
+ a: WritableLocationFields | SetterValue,
+ b?: SetterValue,
+ ) => {
+ const url = new URL(location.href);
+ // `origin` is excluded from WritableLocationFields/LocationSetterRecord's types, but a
+ // plain JS caller could still pass it at runtime — assigning to it would throw, since
+ // it's a getter-only property on URL.
+ if (typeof a === "string") {
+ if ((a as string) === "origin") return;
+ url[a] = accessWith(b as SetterValue, state[a]);
+ } else {
+ const record = accessWith(a, state);
+ for (const [key, value] of entries(record)) {
+ if ((key as string) === "origin" || value == null) continue;
+ url[key] = value;
+ }
+ }
+ updateLocation(url.href, method);
+ };
+
+ return [
+ state,
+ {
+ push: setter.bind(void 0, "push"),
+ replace: setter.bind(void 0, "replace"),
+ navigate: setter.bind(void 0, "navigate"),
+ },
+ ];
+}
+
+/**
+ * Reuses a shared-root {@link createLocationState}, or creates one if one isn't active.
+ *
+ * Use it instead of {@link createLocationState} to avoid recreating signals, computations, and event listeners for every consumer.
+ *
+ * Its interface is the same as {@link createLocationState}, but without a `fallback` argument — use {@link setLocationFallback} to set the server fallback instead.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#useSharedLocationState
+ */
+export const useSharedLocationState = /*#__PURE__*/ createHydratableSingletonRoot(() =>
+ createLocationState(),
+);
+
+/** @internal */
+export const _useLocationState = (useShared: boolean) =>
+ useShared ? useSharedLocationState() : createLocationState();
diff --git a/packages/url/src/searchParams.ts b/packages/url/src/searchParams.ts
new file mode 100644
index 000000000..0f851be26
--- /dev/null
+++ b/packages/url/src/searchParams.ts
@@ -0,0 +1,239 @@
+import { createSingletonRoot } from "@solid-primitives/rootless";
+import { createTriggerCache } from "@solid-primitives/trigger";
+import { accessWith, entries } from "@solid-primitives/utils";
+import { createStore, untrack, type Store } from "solid-js";
+import type { SetterValue } from "./common.js";
+import { _useLocationState, type UpdateLocationMethod } from "./location.js";
+
+export type SearchParamsRecord = Store>;
+export type SearchParamsSetter = {
+ (record: SetterValue): void;
+ (name: string, value?: SetterValue): void;
+};
+
+export type ReactiveSearchParamsInit =
+ string | string[][] | Record | URLSearchParams | ReactiveSearchParams;
+
+/**
+ * Turns a `URLSearchParams` instance (or a string of search params) into a plain record —
+ * a single value for names that appear once, an array of values for names that repeat.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#getSearchParamsRecord
+ * @example
+ * ```ts
+ * const record = getSearchParamsRecord("?foo=bar");
+ * record; // => { foo: "bar" }
+ * ```
+ */
+export function getSearchParamsRecord(
+ searchParams: URLSearchParams | string,
+): Record {
+ const instance =
+ searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams(searchParams);
+ const record: Record = {};
+ instance.forEach((value, name) => {
+ const prev = record[name];
+ if (!prev) record[name] = value;
+ else if (typeof prev === "string") record[name] = [prev, value];
+ else prev.push(value);
+ });
+ return record;
+}
+
+/** @internal */
+function applySearchParamEntry(
+ searchParams: URLSearchParams,
+ name: string,
+ value: string | readonly string[] | undefined,
+): void {
+ if (typeof value === "string") searchParams.set(name, value);
+ else if (!value || value.length === 0) searchParams.delete(name);
+ else {
+ searchParams.set(name, value[0]!);
+ for (let i = 1; i < value.length; i++) searchParams.append(name, value[i]!);
+ }
+}
+
+/**
+ * Provides a reactive record of `URLSearchParams` reflecting `window.location.search`. The
+ * record updates whenever the search params in the browser's URL change, and provides
+ * different setter methods to update the location's search params.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createLocationSearchParams
+ * @param options `useSharedState` — use the shared-root version of the reactive location state. See {@link useSharedLocationState}.
+ * @returns
+ * ```ts
+ * [state, { push, replace, navigate }]
+ * ```
+ * @example
+ * ```ts
+ * const [params, { push }] = createLocationSearchParams();
+ * params.foo; // T: string | string[] | undefined
+ * push({ ...params, page: "2" });
+ * ```
+ */
+export function createLocationSearchParams(options?: { useSharedState?: boolean }): [
+ state: SearchParamsRecord,
+ setters: {
+ push: SearchParamsSetter;
+ replace: SearchParamsSetter;
+ navigate: SearchParamsSetter;
+ },
+] {
+ const [_location, locationSetter] = _useLocationState(!!options?.useSharedState);
+ const [state] = createStore(() => getSearchParamsRecord(_location.search), {});
+
+ const setter = (
+ method: UpdateLocationMethod,
+ a: SetterValue | string,
+ b?: SetterValue,
+ ) => {
+ let searchParams: URLSearchParams;
+ if (typeof a === "string") {
+ searchParams = new URLSearchParams(_location.search);
+ applySearchParamEntry(searchParams, a, accessWith(b, state[a]));
+ } else {
+ searchParams = new URLSearchParams();
+ const record = accessWith(a, state);
+ for (const [name, value] of entries(record)) applySearchParamEntry(searchParams, name, value);
+ }
+ locationSetter[method]("search", searchParams.toString());
+ };
+
+ return [
+ state,
+ {
+ push: setter.bind(void 0, "push"),
+ replace: setter.bind(void 0, "replace"),
+ navigate: setter.bind(void 0, "navigate"),
+ },
+ ];
+}
+
+/**
+ * Reuses a shared-root {@link createLocationSearchParams}, or creates one if one isn't active.
+ *
+ * Use it instead of {@link createLocationSearchParams} to avoid recreating signals, computations, and event listeners for every consumer.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#useSharedLocationSearchParams
+ */
+export const useSharedLocationSearchParams = /*#__PURE__*/ createSingletonRoot(() =>
+ createLocationSearchParams({ useSharedState: true }),
+);
+
+/**
+ * Creates an instance of the {@link ReactiveSearchParams} class — a reactive version of `URLSearchParams`.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createSearchParams
+ */
+export function createSearchParams(init: ReactiveSearchParamsInit = ""): ReactiveSearchParams {
+ return new ReactiveSearchParams(init);
+}
+
+/**
+ * A reactive version of the `URLSearchParams` class. Every read method is granular — it causes
+ * an update only when the value it read has actually changed.
+ *
+ * As this is a reactive structure, it should be instantiated under a reactive root.
+ *
+ * `ReactiveSearchParams` extends `URLSearchParams`, so `x instanceof URLSearchParams` holds.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#ReactiveSearchParams
+ * @example
+ * ```ts
+ * const params = new ReactiveSearchParams("foo=1&foo=2&bar=baz");
+ * createEffect(() => console.log(params.getAll("foo")));
+ * params.append("foo", "3");
+ * ```
+ */
+/** Tracking key standing in for "any part of the collection" — reads that iterate the whole thing (`toString`, `keys`, `entries`, `values`, `forEach`) depend on it, and every mutation dirties it. */
+const WHOLE = Symbol("whole");
+
+export class ReactiveSearchParams extends URLSearchParams {
+ readonly #track: (key: string | typeof WHOLE) => void;
+ readonly #dirty: (key: string | typeof WHOLE) => void;
+ readonly #onChange: VoidFunction | undefined;
+
+ /**
+ * @param init same as the `URLSearchParams` constructor's argument.
+ * @param onChange @internal called after every mutation — used by {@link ReactiveURL} to sync `url.search` from its `.searchParams`.
+ */
+ constructor(init: ReactiveSearchParamsInit, onChange?: VoidFunction) {
+ super(init instanceof ReactiveSearchParams ? untrack(() => init.toString()) : init);
+ // A single lazy TriggerCache — its signals are only created the first time a key is
+ // actually `track`ed from within a reactive read, never eagerly at construction. Mutating
+ // (or constructing) a `ReactiveSearchParams` that no one has read yet is then a plain,
+ // signal-free operation — important since this class gets constructed and written to from
+ // plenty of non-reactive contexts (e.g. `ReactiveURL`'s internal `.search` sync).
+ const [track, dirty] = createTriggerCache();
+ this.#track = track;
+ this.#dirty = dirty;
+ this.#onChange = onChange;
+ }
+
+ #notify(name?: string): void {
+ if (name !== undefined) this.#dirty(name);
+ this.#dirty(WHOLE);
+ this.#onChange?.();
+ }
+
+ // writes
+ override append(name: string, value: string): void {
+ super.append(name, value);
+ this.#notify(name);
+ }
+ override delete(name: string): void {
+ if (!super.has(name)) return;
+ super.delete(name);
+ this.#notify(name);
+ }
+ override set(name: string, value: string): void {
+ const prev = super.getAll(name);
+ if (prev.length === 1 && prev[0] === value) return;
+ super.set(name, value);
+ this.#notify(name);
+ }
+ override sort(): void {
+ super.sort();
+ this.#notify();
+ }
+
+ // reads
+ override get(name: string): string | null {
+ this.#track(name);
+ return super.get(name);
+ }
+ override getAll(name: string): string[] {
+ this.#track(name);
+ return super.getAll(name);
+ }
+ override has(name: string): boolean {
+ this.#track(name);
+ return super.has(name);
+ }
+ override toString(): string {
+ this.#track(WHOLE);
+ return super.toString();
+ }
+ override keys(): ReturnType {
+ this.#track(WHOLE);
+ return super.keys();
+ }
+ override entries(): ReturnType {
+ this.#track(WHOLE);
+ return super.entries();
+ }
+ override values(): ReturnType {
+ this.#track(WHOLE);
+ return super.values();
+ }
+ override forEach(
+ callbackfn: (value: string, key: string, parent: URLSearchParams) => void,
+ ): void {
+ this.#track(WHOLE);
+ super.forEach(callbackfn);
+ }
+ override [Symbol.iterator](): ReturnType {
+ return this.entries();
+ }
+}
diff --git a/packages/url/src/url.ts b/packages/url/src/url.ts
new file mode 100644
index 000000000..fb6d0893d
--- /dev/null
+++ b/packages/url/src/url.ts
@@ -0,0 +1,288 @@
+import { createStaticStore } from "@solid-primitives/static-store";
+import { accessWith, entries } from "@solid-primitives/utils";
+import { pick } from "@solid-primitives/utils/immutable";
+import { untrack } from "solid-js";
+import type { SetterValue } from "./common.js";
+import { ReactiveSearchParams } from "./searchParams.js";
+
+export type URLRecord = {
+ readonly hash: string;
+ readonly host: string;
+ readonly hostname: string;
+ readonly href: string;
+ readonly origin: string;
+ readonly password: string;
+ readonly pathname: string;
+ readonly port: string;
+ readonly protocol: string;
+ readonly search: string;
+ readonly username: string;
+};
+export type WritableURLFields = Exclude;
+
+export type URLSetter = {
+ (record: SetterValue): URLRecord;
+ (key: WritableURLFields, value: SetterValue): URLRecord;
+};
+export type URLSetterRecord = Partial>;
+
+const URL_KEYS = [
+ "hash",
+ "host",
+ "hostname",
+ "href",
+ "origin",
+ "password",
+ "pathname",
+ "port",
+ "protocol",
+ "search",
+ "username",
+] as const satisfies readonly (keyof URLRecord)[];
+
+/**
+ * Provides a `URL` instance as a reactive store.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createURLRecord
+ * @param url
+ * @param base
+ * @returns
+ * ```ts
+ * [store, setter]
+ * ```
+ * @example
+ * ```ts
+ * const [url, setURL] = createURLRecord("http://example.com");
+ * url.host; // => "example.com"
+ * setURL({ hash: "heading1" });
+ * url.hash; // => "#heading1"
+ * ```
+ */
+export function createURLRecord(
+ url: string | URL,
+ base?: string | URL,
+): [store: URLRecord, setter: URLSetter] {
+ const instance = new URL(url, base);
+ const [state, setState] = createStaticStore(pick(instance, ...URL_KEYS));
+
+ const setter = (
+ a: WritableURLFields | SetterValue,
+ b?: SetterValue,
+ ): URLRecord => {
+ if (typeof a === "string") {
+ instance[a] = accessWith(b as SetterValue, instance[a]);
+ } else {
+ const record = accessWith(a, state);
+ for (const [key, value] of entries(record)) {
+ // `origin` is excluded from URLSetterRecord's type, but a plain JS caller
+ // (or one spreading the full URLRecord) could still pass it at runtime —
+ // assigning to it would throw, since it's a getter-only property on URL.
+ if ((key as string) === "origin" || value == null) continue;
+ instance[key] = value;
+ }
+ }
+ for (const key of URL_KEYS) setState(key, instance[key]);
+ // A plain snapshot straight off `instance`, not `state` — `state`'s fields may already be
+ // real (batched) signals if a caller reads them reactively elsewhere, so reading them back
+ // synchronously right after this write, like `ReactiveURL`'s search sync does, could see the
+ // pre-write value until the next flush. `instance` is a plain `URL`, always synchronous.
+ return pick(instance, ...URL_KEYS);
+ };
+
+ return [state, ((a: any, b?: any) => untrack(() => setter(a, b))) as URLSetter];
+}
+
+/**
+ * Creates an instance of the {@link ReactiveURL} class — an object providing reactive setters and getters for managing a URL.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createURL
+ * @param url
+ * @param base
+ * @example
+ * ```ts
+ * const url = createURL("http://example.com");
+ * url.host; // => "example.com"
+ * url.search = "?foo=bar";
+ * createEffect(() => console.log(url.href));
+ * ```
+ */
+export function createURL(url: string, base?: string): ReactiveURL {
+ return new ReactiveURL(url, base);
+}
+
+/**
+ * An object providing reactive setters and getters for managing a URL — every property is
+ * granularly reactive, causing updates only when its value has actually changed.
+ *
+ * As this is a reactive structure, it should be instantiated under a reactive root.
+ *
+ * `ReactiveURL` does **not** extend the `URL` class, so `x instanceof URL` won't work.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#ReactiveURL
+ * @example
+ * ```ts
+ * const url = new ReactiveURL("http://example.com");
+ * url.host; // => "example.com"
+ * url.search = "?foo=bar";
+ * createEffect(() => console.log(url.href));
+ * ```
+ */
+export class ReactiveURL implements Pick {
+ readonly #fields: URLRecord;
+ readonly #set: URLSetter;
+ #searchParamsInstance: ReactiveSearchParams | undefined;
+ // Guards the two-way sync between `.search` and `.searchParams` against re-entrant
+ // ping-pong: a write on one side notifies the other, which would otherwise write back.
+ #syncingSearchParams = false;
+
+ constructor(url: string, base?: string) {
+ const [fields, setURL] = createURLRecord(url, base);
+ this.#fields = fields;
+ this.#set = ((a: any, b?: any): URLRecord =>
+ untrack(() => {
+ const result = setURL(a, b);
+ this.#syncSearchParamsFromSearch(result.search);
+ return result;
+ })) as URLSetter;
+ }
+
+ /** Applies the current `.search` string onto `.searchParams`, diffing so unrelated keys are left untouched. */
+ #syncSearchParamsFromSearch(search: string): void {
+ const rsp = this.#searchParamsInstance;
+ if (!rsp || this.#syncingSearchParams) return;
+ this.#syncingSearchParams = true;
+ try {
+ // `rsp.keys()` is a tracked read (it dirties/tracks the "whole" trigger) — untrack it,
+ // or calling this from inside a host's own reactive render (e.g. Storybook wraps story
+ // renders in a computation) would taint that computation with this internal bookkeeping.
+ untrack(() => {
+ const stale = new Set(rsp.keys());
+ new URLSearchParams(search).forEach((value, name) => {
+ if (stale.delete(name)) rsp.set(name, value);
+ else rsp.append(name, value);
+ });
+ stale.forEach(name => rsp.delete(name));
+ });
+ } finally {
+ this.#syncingSearchParams = false;
+ }
+ }
+
+ get hash(): string {
+ return this.#fields.hash;
+ }
+ set hash(hash: string) {
+ this.#set("hash", hash);
+ }
+
+ get host(): string {
+ return this.#fields.host;
+ }
+ set host(host: string) {
+ this.#set("host", host);
+ }
+
+ get hostname(): string {
+ return this.#fields.hostname;
+ }
+ set hostname(hostname: string) {
+ this.#set("hostname", hostname);
+ }
+
+ get href(): string {
+ return this.#fields.href;
+ }
+ set href(href: string) {
+ this.#set("href", href);
+ }
+
+ get origin(): string {
+ return this.#fields.origin;
+ }
+
+ get password(): string {
+ return this.#fields.password;
+ }
+ set password(password: string) {
+ this.#set("password", password);
+ }
+
+ get pathname(): string {
+ return this.#fields.pathname;
+ }
+ set pathname(pathname: string) {
+ this.#set("pathname", pathname);
+ }
+
+ get port(): string {
+ return this.#fields.port;
+ }
+ set port(port: string) {
+ this.#set("port", port);
+ }
+
+ get protocol(): string {
+ return this.#fields.protocol;
+ }
+ set protocol(protocol: string) {
+ this.#set("protocol", protocol);
+ }
+
+ get search(): string {
+ return this.#fields.search;
+ }
+ set search(search: string) {
+ this.#set("search", search);
+ }
+
+ get username(): string {
+ return this.#fields.username;
+ }
+ set username(username: string) {
+ this.#set("username", username);
+ }
+
+ toString(): string {
+ return this.href;
+ }
+ toJSON(): string {
+ return this.href;
+ }
+
+ /**
+ * Same as in the `URL` class, returns an instance of {@link ReactiveSearchParams}, connected to this `ReactiveURL` instance.
+ *
+ * The reference is stable — the `searchParams` field can be destructured without losing reactivity.
+ * ```ts
+ * const url = new ReactiveURL("http://example.com");
+ * const { searchParams } = url;
+ * createEffect(() => console.log(searchParams.get("foo")));
+ * url.search = "?foo=bar"; // will cause the effect to rerun
+ * ```
+ */
+ get searchParams(): ReactiveSearchParams {
+ let rsp = this.#searchParamsInstance;
+ if (!rsp) {
+ // untrack: this getter's first call is often the initial synchronous read that seeds it
+ // (e.g. a host framework's render/memo wrapping component setup) — reading `this.search`
+ // here shouldn't make *that* computation depend on `.search`, or a later write to it
+ // (from either side of the sync below) would incorrectly retrigger it.
+ rsp = this.#searchParamsInstance = new ReactiveSearchParams(
+ untrack(() => this.search),
+ () => {
+ if (this.#syncingSearchParams) return;
+ this.#syncingSearchParams = true;
+ try {
+ // `rsp.toString()` is a tracked read — see the comment in #syncSearchParamsFromSearch.
+ untrack(() => {
+ this.search = rsp!.toString();
+ });
+ } finally {
+ this.#syncingSearchParams = false;
+ }
+ },
+ );
+ }
+ return rsp;
+ }
+}
diff --git a/packages/url/stories/location.stories.tsx b/packages/url/stories/location.stories.tsx
new file mode 100644
index 000000000..e513155e9
--- /dev/null
+++ b/packages/url/stories/location.stories.tsx
@@ -0,0 +1,67 @@
+import preview from "../../../.storybook/preview.js";
+import { createLocationState } from "@solid-primitives/url";
+import readme from "../README.md?raw";
+import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/URL",
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component: readme,
+ },
+ },
+ },
+});
+
+export default meta;
+
+export const LocationState = meta.story({
+ name: "Push, replace & hash",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createLocationState` reflects `window.location` reactively, and updates on back/forward navigation, `#hash` changes, and `history.pushState`/`replaceState` — including calls made outside this primitive. `push`/`replace` accept a partial record or a `(key, value)` pair; this demo pushes/replaces this *story's own iframe* URL, so it's safe to click around. `navigate` (not wired to a button here, since it forces a full page reload) overwrites `location.href` directly.",
+ },
+ },
+ },
+ render: () => {
+ const [location, { push, replace }] = createLocationState();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/url/stories/locationSearchParams.stories.tsx b/packages/url/stories/locationSearchParams.stories.tsx
new file mode 100644
index 000000000..2d4e62646
--- /dev/null
+++ b/packages/url/stories/locationSearchParams.stories.tsx
@@ -0,0 +1,69 @@
+import { For } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createLocationSearchParams } from "@solid-primitives/url";
+import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/URL",
+ parameters: {
+ layout: "centered",
+ },
+});
+
+export default meta;
+
+const TABS = ["overview", "details", "reviews"];
+
+export const LocationSearchParams = meta.story({
+ name: "Bound to window.location.search",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createLocationSearchParams` provides a reactive record of `window.location.search`, updating whenever the URL's search params change — including from back/forward navigation — with `push`/`replace`/`navigate` setters. `push(name, value)` updates a single param, keeping the rest of the query string intact.",
+ },
+ },
+ },
+ render: () => {
+ const [params, { push }] = createLocationSearchParams();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {tab => (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/url/stories/searchParams.stories.tsx b/packages/url/stories/searchParams.stories.tsx
new file mode 100644
index 000000000..4351b5a7d
--- /dev/null
+++ b/packages/url/stories/searchParams.stories.tsx
@@ -0,0 +1,73 @@
+import { createEffect, createSignal } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createSearchParams } from "@solid-primitives/url";
+import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/URL",
+ parameters: {
+ layout: "centered",
+ },
+});
+
+export default meta;
+
+export const GranularReactivity = meta.story({
+ name: "Granular reactivity",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`ReactiveSearchParams` behaves like `URLSearchParams`, but every read is granular — an effect reading `get('foo')` only reruns when `foo` actually changes, not when an unrelated key is written. The counters below track how many times each effect has rerun.",
+ },
+ },
+ },
+ render: () => {
+ const params = createSearchParams("foo=1&bar=2");
+ const [fooRuns, setFooRuns] = createSignal(0);
+ const [barRuns, setBarRuns] = createSignal(0);
+
+ createEffect(
+ () => params.get("foo"),
+ () => {
+ setFooRuns(n => n + 1);
+ },
+ { defer: true },
+ );
+ createEffect(
+ () => params.get("bar"),
+ () => {
+ setBarRuns(n => n + 1);
+ },
+ { defer: true },
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/url/stories/url.stories.tsx b/packages/url/stories/url.stories.tsx
new file mode 100644
index 000000000..6d001ac9c
--- /dev/null
+++ b/packages/url/stories/url.stories.tsx
@@ -0,0 +1,115 @@
+import { createSignal, For } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createURL } from "@solid-primitives/url";
+import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/URL",
+ parameters: {
+ layout: "centered",
+ },
+});
+
+export default meta;
+
+const INITIAL = "https://shop.example.com/products?category=shoes&sort=price";
+
+export const ReactiveURLFields = meta.story({
+ name: "Granular reactive fields",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createURL` returns a `ReactiveURL` — same interface as the native `URL` class, but every getter is granularly reactive. Editing `pathname` doesn't touch `search` or `hash`, and vice versa.",
+ },
+ },
+ },
+ render: () => {
+ const url = createURL(INITIAL);
+ const paths = ["/products", "/products/sneakers", "/checkout"];
+ const [pathIdx, setPathIdx] = createSignal(0);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {(p, i) => (
+
+ )}
+
+
+
+
+
+
+
+ );
+ },
+});
+
+export const TwoWaySearchParamsSync = meta.story({
+ name: ".search ↔ .searchParams sync",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`url.searchParams` returns a `ReactiveSearchParams` kept in sync with `url.search` in both directions — the reference is stable, so it can be destructured once. Mutating either side updates the other.",
+ },
+ },
+ },
+ render: () => {
+ const url = createURL(INITIAL);
+ const { searchParams } = url;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/url/test/location.test.ts b/packages/url/test/location.test.ts
new file mode 100644
index 000000000..7af8187a0
--- /dev/null
+++ b/packages/url/test/location.test.ts
@@ -0,0 +1,106 @@
+import { createRoot } from "solid-js";
+import { describe, test, expect, afterEach } from "vitest";
+import { createLocationState, updateLocation } from "../src/index.js";
+
+const origin = location.origin;
+const rootHref = origin + "/";
+
+describe("createLocationState", () => {
+ afterEach(() => {
+ history.replaceState(null, "", rootHref);
+ });
+
+ test("reflects the current window.location", () =>
+ createRoot(dispose => {
+ const [state] = createLocationState();
+ expect(state.pathname).toBe("/");
+ expect(state.href).toBe(rootHref);
+ dispose();
+ }));
+
+ test("updates when history.pushState is called directly", () =>
+ createRoot(dispose => {
+ const [state] = createLocationState();
+ history.pushState(null, "", "/foo?bar=1");
+ expect(state.pathname).toBe("/foo");
+ expect(state.search).toBe("?bar=1");
+ dispose();
+ }));
+
+ test("updates when history.replaceState is called directly", () =>
+ createRoot(dispose => {
+ const [state] = createLocationState();
+ history.replaceState(null, "", "/replaced-directly");
+ expect(state.pathname).toBe("/replaced-directly");
+ dispose();
+ }));
+
+ test("updates on popstate", () =>
+ createRoot(dispose => {
+ const [state] = createLocationState();
+ history.pushState(null, "", "/after-pop");
+ window.dispatchEvent(new Event("popstate"));
+ expect(state.pathname).toBe("/after-pop");
+ dispose();
+ }));
+
+ test("push/replace setters update the location", () =>
+ createRoot(dispose => {
+ const [state, { push, replace }] = createLocationState();
+
+ push({ pathname: "/push-test" });
+ expect(state.pathname).toBe("/push-test");
+
+ replace({ pathname: "/replace-test" });
+ expect(state.pathname).toBe("/replace-test");
+
+ dispose();
+ }));
+
+ test("supports the (key, value) setter overload", () =>
+ createRoot(dispose => {
+ const [state, { push }] = createLocationState();
+ push("hash", "heading1");
+ expect(state.hash).toBe("#heading1");
+ dispose();
+ }));
+
+ test("supports an updater function", () =>
+ createRoot(dispose => {
+ const [state, { push }] = createLocationState();
+ push(prev => ({ pathname: prev.pathname + "nested" }));
+ expect(state.pathname).toBe("/nested");
+ dispose();
+ }));
+
+ test("origin cannot be overwritten", () =>
+ createRoot(dispose => {
+ const [state, { push }] = createLocationState();
+ // @ts-expect-error origin is read-only
+ push({ origin: "http://evil.com", pathname: "/safe" });
+ expect(state.origin).toBe(origin);
+ expect(state.pathname).toBe("/safe");
+ dispose();
+ }));
+});
+
+describe("updateLocation", () => {
+ afterEach(() => {
+ history.replaceState(null, "", rootHref);
+ });
+
+ test("push uses history.pushState", () => {
+ const before = history.length;
+ updateLocation(origin + "/pushed", "push");
+ expect(location.pathname).toBe("/pushed");
+ expect(history.length).toBe(before + 1);
+ });
+
+ test("replace uses history.replaceState", () => {
+ updateLocation(origin + "/before-replace", "push");
+ const before = history.length;
+ updateLocation(origin + "/replaced", "replace");
+ expect(location.pathname).toBe("/replaced");
+ expect(history.length).toBe(before);
+ });
+});
diff --git a/packages/url/test/searchParams.test.ts b/packages/url/test/searchParams.test.ts
new file mode 100644
index 000000000..70f9357bf
--- /dev/null
+++ b/packages/url/test/searchParams.test.ts
@@ -0,0 +1,119 @@
+import { createEffect, createRoot, flush } from "solid-js";
+import { describe, test, expect, afterEach, vi } from "vitest";
+import {
+ createLocationSearchParams,
+ createSearchParams,
+ getSearchParamsRecord,
+ ReactiveSearchParams,
+} from "../src/index.js";
+
+const origin = location.origin;
+
+describe("getSearchParamsRecord", () => {
+ test("single values map to strings, repeated names to arrays", () => {
+ expect(getSearchParamsRecord("?foo=bar")).toEqual({ foo: "bar" });
+ expect(getSearchParamsRecord("?foo=1&foo=2&bar=baz")).toEqual({
+ foo: ["1", "2"],
+ bar: "baz",
+ });
+ expect(getSearchParamsRecord("")).toEqual({});
+ });
+
+ test("accepts a URLSearchParams instance", () => {
+ expect(getSearchParamsRecord(new URLSearchParams("a=1"))).toEqual({ a: "1" });
+ });
+});
+
+describe("ReactiveSearchParams", () => {
+ test("behaves like URLSearchParams", () => {
+ const params = createSearchParams("foo=1&foo=2&bar=baz");
+ expect(params).toBeInstanceOf(URLSearchParams);
+ expect(params.get("foo")).toBe("1");
+ expect(params.getAll("foo")).toEqual(["1", "2"]);
+ expect(params.toString()).toBe("foo=1&foo=2&bar=baz");
+ });
+
+ test("is granularly reactive per key", () =>
+ createRoot(dispose => {
+ const params = new ReactiveSearchParams("foo=1&bar=2");
+ const fooUpdates = vi.fn();
+ const barUpdates = vi.fn();
+
+ createEffect(() => params.get("foo"), fooUpdates, { defer: true });
+ createEffect(() => params.get("bar"), barUpdates, { defer: true });
+
+ params.set("foo", "9");
+ flush();
+ expect(fooUpdates).toHaveBeenCalledTimes(1);
+ expect(fooUpdates).toHaveBeenCalledWith("9", undefined);
+ expect(barUpdates).not.toHaveBeenCalled();
+
+ dispose();
+ }));
+
+ test("set() is a no-op when the value doesn't change", () =>
+ createRoot(dispose => {
+ const params = new ReactiveSearchParams("foo=1");
+ const updates = vi.fn();
+
+ createEffect(() => params.get("foo"), updates, { defer: true });
+
+ params.set("foo", "1");
+ flush();
+ expect(updates).not.toHaveBeenCalled();
+
+ dispose();
+ }));
+
+ test("delete() removes the key", () => {
+ const params = createSearchParams("foo=1&bar=2");
+ params.delete("foo");
+ expect(params.has("foo")).toBe(false);
+ expect(params.toString()).toBe("bar=2");
+ });
+});
+
+describe("createLocationSearchParams", () => {
+ afterEach(() => {
+ history.replaceState(null, "", origin + "/");
+ });
+
+ test("reflects window.location.search", () =>
+ createRoot(dispose => {
+ history.replaceState(null, "", "/?foo=bar");
+ const [params] = createLocationSearchParams();
+ expect(params.foo).toBe("bar");
+ dispose();
+ }));
+
+ test("updates when the location's search changes", () =>
+ createRoot(dispose => {
+ const [params] = createLocationSearchParams();
+ history.pushState(null, "", "/?a=1");
+ flush();
+ expect(params.a).toBe("1");
+ dispose();
+ }));
+
+ test("push sets a single param via the (name, value) overload", () =>
+ createRoot(dispose => {
+ const [params, { push }] = createLocationSearchParams();
+ push("page", "2");
+ flush();
+ expect(params.page).toBe("2");
+ expect(location.search).toBe("?page=2");
+ dispose();
+ }));
+
+ test("push replaces every param via the record overload", () =>
+ createRoot(dispose => {
+ const [params, { push }] = createLocationSearchParams();
+ push({ a: "1" });
+ flush();
+ push({ b: "2" });
+ flush();
+ expect(params.a).toBeUndefined();
+ expect(params.b).toBe("2");
+ dispose();
+ }));
+});
diff --git a/packages/url/test/server.test.ts b/packages/url/test/server.test.ts
new file mode 100644
index 000000000..2fe4a26da
--- /dev/null
+++ b/packages/url/test/server.test.ts
@@ -0,0 +1,81 @@
+import { getRequestEvent } from "@solidjs/web";
+import { createRoot } from "solid-js";
+import { describe, it, expect, vi } from "vitest";
+import {
+ createLocationState,
+ createURL,
+ createSearchParams,
+ setLocationFallback,
+} from "../src/index.js";
+
+vi.mock("@solidjs/web", async importOriginal => ({
+ ...(await importOriginal()),
+ getRequestEvent: vi.fn(),
+}));
+
+describe("SSR", () => {
+ it("createLocationState throws without a fallback", () =>
+ createRoot(dispose => {
+ expect(() => createLocationState()).toThrow();
+ dispose();
+ }));
+
+ it("createLocationState uses the provided fallback", () =>
+ createRoot(dispose => {
+ const [state, { push }] = createLocationState("http://example.com/path?foo=bar");
+ expect(state.href).toBe("http://example.com/path?foo=bar");
+ expect(state.pathname).toBe("/path");
+ // setters are no-ops on the server
+ expect(() => push({ pathname: "/other" })).not.toThrow();
+ dispose();
+ }));
+
+ it("createLocationState uses the global fallback set via setLocationFallback", () =>
+ createRoot(dispose => {
+ setLocationFallback("http://example.com/global-fallback");
+ const [state] = createLocationState();
+ expect(state.pathname).toBe("/global-fallback");
+ dispose();
+ }));
+
+ it("createLocationState derives its fallback from getRequestEvent, taking priority over the global fallback", () =>
+ createRoot(dispose => {
+ setLocationFallback("http://example.com/global-fallback");
+ vi.mocked(getRequestEvent).mockReturnValueOnce({
+ request: new Request("http://example.com/from-request-event"),
+ locals: {},
+ });
+ const [state] = createLocationState();
+ expect(state.pathname).toBe("/from-request-event");
+ dispose();
+ }));
+
+ it("an explicit fallback argument takes priority over getRequestEvent", () =>
+ createRoot(dispose => {
+ vi.mocked(getRequestEvent).mockReturnValueOnce({
+ request: new Request("http://example.com/from-request-event"),
+ locals: {},
+ });
+ const [state] = createLocationState("http://example.com/explicit-fallback");
+ expect(state.pathname).toBe("/explicit-fallback");
+ dispose();
+ }));
+
+ it("createURL / ReactiveURL works without a DOM", () =>
+ createRoot(dispose => {
+ const url = createURL("http://example.com/path?foo=bar");
+ expect(url.pathname).toBe("/path");
+ url.pathname = "/other";
+ expect(url.href).toBe("http://example.com/other?foo=bar");
+ dispose();
+ }));
+
+ it("createSearchParams / ReactiveSearchParams works without a DOM", () =>
+ createRoot(dispose => {
+ const params = createSearchParams("foo=bar");
+ expect(params.get("foo")).toBe("bar");
+ params.set("foo", "baz");
+ expect(params.get("foo")).toBe("baz");
+ dispose();
+ }));
+});
diff --git a/packages/url/test/url.test.ts b/packages/url/test/url.test.ts
new file mode 100644
index 000000000..a8934ac89
--- /dev/null
+++ b/packages/url/test/url.test.ts
@@ -0,0 +1,132 @@
+import { createEffect, createRoot, flush } from "solid-js";
+import { describe, test, expect, vi } from "vitest";
+import { createURL, createURLRecord } from "../src/index.js";
+
+const inithref = "http://example.com/path?foo=bar#hash";
+
+describe("ReactiveURL", () => {
+ test("behaves like a URL instance", () =>
+ createRoot(dispose => {
+ const native = new URL(inithref);
+ const url = createURL(inithref);
+
+ expect(url.href).toBe(native.href);
+ expect(url.pathname).toBe(native.pathname);
+ expect(url.search).toBe(native.search);
+ expect(url.hash).toBe(native.hash);
+ expect(url.origin).toBe(native.origin);
+ expect(url.toString()).toBe(native.toString());
+ expect(url.toJSON()).toBe(native.toJSON());
+
+ dispose();
+ }));
+
+ test("setters update every derived field", () =>
+ createRoot(dispose => {
+ const url = createURL(inithref);
+
+ url.pathname = "/other";
+ expect(url.pathname).toBe("/other");
+ expect(url.href).toBe("http://example.com/other?foo=bar#hash");
+
+ url.search = "?baz=qux";
+ expect(url.search).toBe("?baz=qux");
+ expect(url.href).toBe("http://example.com/other?baz=qux#hash");
+
+ dispose();
+ }));
+
+ test("is reactive — updates trigger effects", () =>
+ createRoot(dispose => {
+ const url = createURL(inithref);
+ const hrefUpdates = vi.fn();
+ const hashUpdates = vi.fn();
+
+ createEffect(() => url.href, hrefUpdates, { defer: true });
+ createEffect(() => url.hash, hashUpdates, { defer: true });
+
+ url.search = "?a=1";
+ flush();
+ expect(hrefUpdates).toHaveBeenCalledTimes(1);
+ expect(hrefUpdates).toHaveBeenCalledWith("http://example.com/path?a=1#hash", undefined);
+ expect(hashUpdates).not.toHaveBeenCalled();
+
+ dispose();
+ }));
+
+ test("origin cannot be reassigned through the record setter", () =>
+ createRoot(dispose => {
+ const [state, setURL] = createURLRecord(inithref);
+ // @ts-expect-error origin is read-only
+ setURL({ origin: "http://evil.com", pathname: "/safe" });
+ expect(state.origin).toBe("http://example.com");
+ expect(state.pathname).toBe("/safe");
+ dispose();
+ }));
+
+ describe("searchParams", () => {
+ test("reflects the current search string, and stays referentially stable", () =>
+ createRoot(dispose => {
+ const url = createURL(inithref);
+ const { searchParams } = url;
+
+ expect(searchParams.get("foo")).toBe("bar");
+ expect(url.searchParams).toBe(searchParams);
+
+ dispose();
+ }));
+
+ test("mutating searchParams updates url.search", () =>
+ createRoot(dispose => {
+ const url = createURL(inithref);
+
+ url.searchParams.set("foo", "baz");
+ expect(url.search).toBe("?foo=baz");
+
+ url.searchParams.append("extra", "1");
+ expect(url.search).toBe("?foo=baz&extra=1");
+
+ dispose();
+ }));
+
+ test("writing url.search updates searchParams", () =>
+ createRoot(dispose => {
+ const url = createURL(inithref);
+ const { searchParams } = url;
+
+ url.search = "?a=1&b=2";
+ flush();
+
+ expect(searchParams.get("a")).toBe("1");
+ expect(searchParams.get("b")).toBe("2");
+ expect(searchParams.has("foo")).toBe(false);
+
+ dispose();
+ }));
+
+ test("preserves repeated keys when re-synced from url.search", () =>
+ createRoot(dispose => {
+ const url = createURL(inithref);
+ const { searchParams } = url;
+
+ url.search = "?tag=a&tag=b";
+ flush();
+
+ expect(searchParams.getAll("tag")).toEqual(["a", "b"]);
+
+ dispose();
+ }));
+
+ test("sorting searchParams updates url.search and href", () =>
+ createRoot(dispose => {
+ const url = createURL("http://example.com/path?b=2&a=1");
+
+ url.searchParams.sort();
+
+ expect(url.search).toBe("?a=1&b=2");
+ expect(url.href).toBe("http://example.com/path?a=1&b=2");
+
+ dispose();
+ }));
+ });
+});
diff --git a/packages/url/tsconfig.json b/packages/url/tsconfig.json
new file mode 100644
index 000000000..b667dd50b
--- /dev/null
+++ b/packages/url/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "references": [
+ {
+ "path": "../event-listener"
+ },
+ {
+ "path": "../rootless"
+ },
+ {
+ "path": "../static-store"
+ },
+ {
+ "path": "../trigger"
+ },
+ {
+ "path": "../utils"
+ }
+ ],
+ "include": [
+ "src"
+ ]
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6125721c..e4457e56b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1321,6 +1321,31 @@ importers:
specifier: 2.0.0-beta.15
version: 2.0.0-beta.15
+ packages/url:
+ dependencies:
+ '@solid-primitives/event-listener':
+ specifier: workspace:^
+ version: link:../event-listener
+ '@solid-primitives/rootless':
+ specifier: workspace:^
+ version: link:../rootless
+ '@solid-primitives/static-store':
+ specifier: workspace:^
+ version: link:../static-store
+ '@solid-primitives/trigger':
+ specifier: workspace:^
+ version: link:../trigger
+ '@solid-primitives/utils':
+ specifier: workspace:^
+ version: link:../utils
+ devDependencies:
+ '@solidjs/web':
+ specifier: 2.0.0-beta.15
+ version: 2.0.0-beta.15(solid-js@2.0.0-beta.15)
+ solid-js:
+ specifier: 2.0.0-beta.15
+ version: 2.0.0-beta.15
+
packages/utils:
devDependencies:
'@solidjs/web':