|
|[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..38b41d975
--- /dev/null
+++ b/packages/url/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 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.
\ No newline at end of file
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..48127ff2e
--- /dev/null
+++ b/packages/url/src/common.ts
@@ -0,0 +1,2 @@
+/** A value to write, or a function deriving it from the previous value. */
+export type SetterValue = 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..bcb8a75ba
--- /dev/null
+++ b/packages/url/src/location.ts
@@ -0,0 +1,195 @@
+import { makeEventListener } from "@solid-primitives/event-listener";
+import { createSingletonRoot } 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 { 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 {@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) {
+ const state = fallback ? getLocationStateFromFallback(fallback) : locationFallback;
+ if (!state) {
+ throw new Error(
+ "createLocationState requires a location fallback for server execution. " +
+ "Pass one as an argument, or set a global one with setLocationFallback.",
+ );
+ }
+ 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);
+ if (typeof a === "string") {
+ url[a] = accessWith(b as SetterValue, state[a]);
+ } else {
+ const record = accessWith(a, state);
+ for (const [key, value] of entries(record)) {
+ // `origin` is excluded from LocationSetterRecord's type, but a plain JS caller
+ // (or one spreading the full LocationState) 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;
+ 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__*/ createSingletonRoot(() =>
+ 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..924c407b4
--- /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, 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 ? 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.#dirty(WHOLE);
+ }
+
+ // 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..9d1ebfa6e
--- /dev/null
+++ b/packages/url/test/location.test.ts
@@ -0,0 +1,105 @@
+import { createRoot } from "solid-js";
+import { describe, test, expect, afterEach } from "vitest";
+import { createLocationState, updateLocation } from "../src/index.js";
+
+const origin = location.origin;
+
+describe("createLocationState", () => {
+ afterEach(() => {
+ history.replaceState(null, "", origin + "/");
+ });
+
+ test("reflects the current window.location", () =>
+ createRoot(dispose => {
+ const [state] = createLocationState();
+ expect(state.pathname).toBe("/");
+ expect(state.href).toBe(origin + "/");
+ 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, "", origin + "/");
+ });
+
+ 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..6a1467ee3
--- /dev/null
+++ b/packages/url/test/searchParams.test.ts
@@ -0,0 +1,136 @@
+import { createEffect, createRoot, flush } from "solid-js";
+import { describe, test, expect, afterEach } 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");
+ let fooUpdates = 0;
+ let barUpdates = 0;
+
+ createEffect(
+ () => params.get("foo"),
+ () => {
+ fooUpdates++;
+ },
+ { defer: true },
+ );
+ createEffect(
+ () => params.get("bar"),
+ () => {
+ barUpdates++;
+ },
+ { defer: true },
+ );
+
+ params.set("foo", "9");
+ flush();
+ expect(fooUpdates, "foo changed").toBe(1);
+ expect(barUpdates, "bar did not change").toBe(0);
+
+ dispose();
+ }));
+
+ test("set() is a no-op when the value doesn't change", () =>
+ createRoot(dispose => {
+ const params = new ReactiveSearchParams("foo=1");
+ let updates = 0;
+
+ createEffect(
+ () => params.get("foo"),
+ () => {
+ updates++;
+ },
+ { defer: true },
+ );
+
+ params.set("foo", "1");
+ flush();
+ expect(updates).toBe(0);
+
+ 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..a74dc4901
--- /dev/null
+++ b/packages/url/test/server.test.ts
@@ -0,0 +1,52 @@
+import { createRoot } from "solid-js";
+import { describe, it, expect } from "vitest";
+import {
+ createLocationState,
+ createURL,
+ createSearchParams,
+ setLocationFallback,
+} from "../src/index.js";
+
+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("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..c9f059456
--- /dev/null
+++ b/packages/url/test/url.test.ts
@@ -0,0 +1,131 @@
+import { createEffect, createRoot, flush } from "solid-js";
+import { describe, test, expect } 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);
+ let hrefUpdates = 0;
+ let hashUpdates = 0;
+
+ createEffect(
+ () => url.href,
+ () => {
+ hrefUpdates++;
+ },
+ { defer: true },
+ );
+ createEffect(
+ () => url.hash,
+ () => {
+ hashUpdates++;
+ },
+ { defer: true },
+ );
+
+ url.search = "?a=1";
+ flush();
+ expect(hrefUpdates, "href changed").toBe(1);
+ expect(hashUpdates, "hash did not change").toBe(0);
+
+ 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();
+ }));
+ });
+});
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 ce94c633b..548eb8141 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1318,6 +1318,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':
From 5ca60ee147be93edbbd346a513ab1063b9a571fd Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:09:43 -0400
Subject: [PATCH 2/6] Untracked copy: constructing from an existing
ReactiveSearchParams creates an implicit dependency
---
packages/url/src/searchParams.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/url/src/searchParams.ts b/packages/url/src/searchParams.ts
index 924c407b4..633bb8686 100644
--- a/packages/url/src/searchParams.ts
+++ b/packages/url/src/searchParams.ts
@@ -1,7 +1,7 @@
import { createSingletonRoot } from "@solid-primitives/rootless";
import { createTriggerCache } from "@solid-primitives/trigger";
import { accessWith, entries } from "@solid-primitives/utils";
-import { createStore, type Store } from "solid-js";
+import { createStore, untrack, type Store } from "solid-js";
import type { SetterValue } from "./common.js";
import { _useLocationState, type UpdateLocationMethod } from "./location.js";
@@ -159,7 +159,7 @@ export class ReactiveSearchParams extends URLSearchParams {
* @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 ? init.toString() : init);
+ 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,
From f97679be0e3dc89ee5895e1ddeb60d8b331d92bc Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:11:09 -0400
Subject: [PATCH 3/6] sort() should call #notify() so ReactiveURL.search stays
in sync
---
packages/url/src/searchParams.ts | 2 +-
packages/url/test/url.test.ts | 12 ++++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/packages/url/src/searchParams.ts b/packages/url/src/searchParams.ts
index 633bb8686..0f851be26 100644
--- a/packages/url/src/searchParams.ts
+++ b/packages/url/src/searchParams.ts
@@ -195,7 +195,7 @@ export class ReactiveSearchParams extends URLSearchParams {
}
override sort(): void {
super.sort();
- this.#dirty(WHOLE);
+ this.#notify();
}
// reads
diff --git a/packages/url/test/url.test.ts b/packages/url/test/url.test.ts
index c9f059456..36fe7649b 100644
--- a/packages/url/test/url.test.ts
+++ b/packages/url/test/url.test.ts
@@ -125,6 +125,18 @@ describe("ReactiveURL", () => {
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();
}));
});
From 32157d2533fd813257b0af2bfa1b938b02b9ba2a Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:12:05 -0400
Subject: [PATCH 4/6] Missing origin guard on the (key, value) setter overload
---
packages/url/src/location.ts | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/packages/url/src/location.ts b/packages/url/src/location.ts
index bcb8a75ba..ae647396a 100644
--- a/packages/url/src/location.ts
+++ b/packages/url/src/location.ts
@@ -152,14 +152,15 @@ export function createLocationState(fallback?: LocationFallbackInit): [
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)) {
- // `origin` is excluded from LocationSetterRecord's type, but a plain JS caller
- // (or one spreading the full LocationState) 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;
url[key] = value;
}
From c5f9d4445a22b57d176d308ff0a424f3c5339bb6 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:14:40 -0400
Subject: [PATCH 5/6] Avoid process-wide location state in SSR
---
packages/url/src/location.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/url/src/location.ts b/packages/url/src/location.ts
index ae647396a..6fbac6385 100644
--- a/packages/url/src/location.ts
+++ b/packages/url/src/location.ts
@@ -1,5 +1,5 @@
import { makeEventListener } from "@solid-primitives/event-listener";
-import { createSingletonRoot } from "@solid-primitives/rootless";
+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";
@@ -187,7 +187,7 @@ export function createLocationState(fallback?: LocationFallbackInit): [
*
* @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#useSharedLocationState
*/
-export const useSharedLocationState = /*#__PURE__*/ createSingletonRoot(() =>
+export const useSharedLocationState = /*#__PURE__*/ createHydratableSingletonRoot(() =>
createLocationState(),
);
From e91029b0ba852ac7e7f778669898f62e15fcd615 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Tue, 7 Jul 2026 09:15:05 -0400
Subject: [PATCH 6/6] Applied review notes from team
---
packages/url/LICENSE | 4 +--
packages/url/src/common.ts | 3 +-
packages/url/src/location.ts | 17 ++++++++---
packages/url/test/location.test.ts | 7 +++--
packages/url/test/searchParams.test.ts | 41 ++++++++------------------
packages/url/test/server.test.ts | 31 ++++++++++++++++++-
packages/url/test/url.test.ts | 29 ++++++------------
7 files changed, 72 insertions(+), 60 deletions(-)
diff --git a/packages/url/LICENSE b/packages/url/LICENSE
index 38b41d975..ba8f69c3d 100644
--- a/packages/url/LICENSE
+++ b/packages/url/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2021 Solid Primitives Working Group
+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
@@ -18,4 +18,4 @@ 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.
\ No newline at end of file
+SOFTWARE.
diff --git a/packages/url/src/common.ts b/packages/url/src/common.ts
index 48127ff2e..8a28cba54 100644
--- a/packages/url/src/common.ts
+++ b/packages/url/src/common.ts
@@ -1,2 +1,3 @@
/** A value to write, or a function deriving it from the previous value. */
-export type SetterValue = Next | ((prev: Prev) => Next);
+export type SetterValue =
+ Next extends Function ? (prev: Prev) => Next : Next | ((prev: Prev) => Next);
diff --git a/packages/url/src/location.ts b/packages/url/src/location.ts
index 6fbac6385..b995701f3 100644
--- a/packages/url/src/location.ts
+++ b/packages/url/src/location.ts
@@ -3,7 +3,7 @@ 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 { isServer } from "@solidjs/web";
+import { getRequestEvent, isServer } from "@solidjs/web";
import type { SetterValue } from "./common.js";
export type LocationState = {
@@ -106,7 +106,7 @@ export function setLocationFallback(fallback: LocationFallbackInit): void {
* 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 {@link setLocationFallback}'s value if not provided.
+ * @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 }]
@@ -127,11 +127,20 @@ export function createLocationState(fallback?: LocationFallbackInit): [
},
] {
if (isServer) {
- const state = fallback ? getLocationStateFromFallback(fallback) : locationFallback;
+ 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, or set a global one with setLocationFallback.",
+ "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 }];
diff --git a/packages/url/test/location.test.ts b/packages/url/test/location.test.ts
index 9d1ebfa6e..7af8187a0 100644
--- a/packages/url/test/location.test.ts
+++ b/packages/url/test/location.test.ts
@@ -3,17 +3,18 @@ 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, "", origin + "/");
+ history.replaceState(null, "", rootHref);
});
test("reflects the current window.location", () =>
createRoot(dispose => {
const [state] = createLocationState();
expect(state.pathname).toBe("/");
- expect(state.href).toBe(origin + "/");
+ expect(state.href).toBe(rootHref);
dispose();
}));
@@ -85,7 +86,7 @@ describe("createLocationState", () => {
describe("updateLocation", () => {
afterEach(() => {
- history.replaceState(null, "", origin + "/");
+ history.replaceState(null, "", rootHref);
});
test("push uses history.pushState", () => {
diff --git a/packages/url/test/searchParams.test.ts b/packages/url/test/searchParams.test.ts
index 6a1467ee3..70f9357bf 100644
--- a/packages/url/test/searchParams.test.ts
+++ b/packages/url/test/searchParams.test.ts
@@ -1,5 +1,5 @@
import { createEffect, createRoot, flush } from "solid-js";
-import { describe, test, expect, afterEach } from "vitest";
+import { describe, test, expect, afterEach, vi } from "vitest";
import {
createLocationSearchParams,
createSearchParams,
@@ -36,28 +36,17 @@ describe("ReactiveSearchParams", () => {
test("is granularly reactive per key", () =>
createRoot(dispose => {
const params = new ReactiveSearchParams("foo=1&bar=2");
- let fooUpdates = 0;
- let barUpdates = 0;
-
- createEffect(
- () => params.get("foo"),
- () => {
- fooUpdates++;
- },
- { defer: true },
- );
- createEffect(
- () => params.get("bar"),
- () => {
- barUpdates++;
- },
- { defer: true },
- );
+ 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, "foo changed").toBe(1);
- expect(barUpdates, "bar did not change").toBe(0);
+ expect(fooUpdates).toHaveBeenCalledTimes(1);
+ expect(fooUpdates).toHaveBeenCalledWith("9", undefined);
+ expect(barUpdates).not.toHaveBeenCalled();
dispose();
}));
@@ -65,19 +54,13 @@ describe("ReactiveSearchParams", () => {
test("set() is a no-op when the value doesn't change", () =>
createRoot(dispose => {
const params = new ReactiveSearchParams("foo=1");
- let updates = 0;
+ const updates = vi.fn();
- createEffect(
- () => params.get("foo"),
- () => {
- updates++;
- },
- { defer: true },
- );
+ createEffect(() => params.get("foo"), updates, { defer: true });
params.set("foo", "1");
flush();
- expect(updates).toBe(0);
+ expect(updates).not.toHaveBeenCalled();
dispose();
}));
diff --git a/packages/url/test/server.test.ts b/packages/url/test/server.test.ts
index a74dc4901..2fe4a26da 100644
--- a/packages/url/test/server.test.ts
+++ b/packages/url/test/server.test.ts
@@ -1,5 +1,6 @@
+import { getRequestEvent } from "@solidjs/web";
import { createRoot } from "solid-js";
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import {
createLocationState,
createURL,
@@ -7,6 +8,11 @@ import {
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 => {
@@ -32,6 +38,29 @@ describe("SSR", () => {
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");
diff --git a/packages/url/test/url.test.ts b/packages/url/test/url.test.ts
index 36fe7649b..a8934ac89 100644
--- a/packages/url/test/url.test.ts
+++ b/packages/url/test/url.test.ts
@@ -1,5 +1,5 @@
import { createEffect, createRoot, flush } from "solid-js";
-import { describe, test, expect } from "vitest";
+import { describe, test, expect, vi } from "vitest";
import { createURL, createURLRecord } from "../src/index.js";
const inithref = "http://example.com/path?foo=bar#hash";
@@ -39,28 +39,17 @@ describe("ReactiveURL", () => {
test("is reactive — updates trigger effects", () =>
createRoot(dispose => {
const url = createURL(inithref);
- let hrefUpdates = 0;
- let hashUpdates = 0;
-
- createEffect(
- () => url.href,
- () => {
- hrefUpdates++;
- },
- { defer: true },
- );
- createEffect(
- () => url.hash,
- () => {
- hashUpdates++;
- },
- { defer: true },
- );
+ 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, "href changed").toBe(1);
- expect(hashUpdates, "hash did not change").toBe(0);
+ expect(hrefUpdates).toHaveBeenCalledTimes(1);
+ expect(hrefUpdates).toHaveBeenCalledWith("http://example.com/path?a=1#hash", undefined);
+ expect(hashUpdates).not.toHaveBeenCalled();
dispose();
}));