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 @@ [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg?style=for-the-badge&logo=pnpm)](https://pnpm.io/) [![tested with vitest](https://img.shields.io/badge/tested_with-vitest-6E9F18?style=for-the-badge&logo=vitest)](https://vitest.dev) -[![combined-downloads](https://img.shields.io/endpoint?style=for-the-badge&url=https://combined-npm-downloads.deno.dev/@solid-primitives/a11y,@solid-primitives/active-element,@solid-primitives/analytics,@solid-primitives/async,@solid-primitives/audio,@solid-primitives/bounds,@solid-primitives/broadcast-channel,@solid-primitives/clipboard,@solid-primitives/connectivity,@solid-primitives/context,@solid-primitives/controlled-props,@solid-primitives/controlled-signal,@solid-primitives/cookies,@solid-primitives/cursor,@solid-primitives/date,@solid-primitives/db-store,@solid-primitives/deep,@solid-primitives/destructure,@solid-primitives/devices,@solid-primitives/event-bus,@solid-primitives/event-dispatcher,@solid-primitives/event-listener,@solid-primitives/event-props,@solid-primitives/fetch,@solid-primitives/filesystem,@solid-primitives/flux-store,@solid-primitives/focus,@solid-primitives/form,@solid-primitives/fullscreen,@solid-primitives/geolocation,@solid-primitives/graphql,@solid-primitives/history,@solid-primitives/i18n,@solid-primitives/idle,@solid-primitives/immutable,@solid-primitives/input-mask,@solid-primitives/interaction,@solid-primitives/intersection-observer,@solid-primitives/jsx-tokenizer,@solid-primitives/keyboard,@solid-primitives/keyed,@solid-primitives/lifecycle,@solid-primitives/list,@solid-primitives/list-state,@solid-primitives/map,@solid-primitives/marker,@solid-primitives/masonry,@solid-primitives/match,@solid-primitives/media,@solid-primitives/mediastream,@solid-primitives/memo,@solid-primitives/mouse,@solid-primitives/mutable,@solid-primitives/mutation-observer,@solid-primitives/notification,@solid-primitives/orientation,@solid-primitives/page-utilities,@solid-primitives/pagination,@solid-primitives/permission,@solid-primitives/platform,@solid-primitives/pointer,@solid-primitives/presence,@solid-primitives/promise,@solid-primitives/props,@solid-primitives/queue,@solid-primitives/raf,@solid-primitives/range,@solid-primitives/refs,@solid-primitives/resize-observer,@solid-primitives/resource,@solid-primitives/rootless,@solid-primitives/scheduled,@solid-primitives/script-loader,@solid-primitives/scroll,@solid-primitives/selection,@solid-primitives/sensors,@solid-primitives/set,@solid-primitives/share,@solid-primitives/signal-builders,@solid-primitives/spring,@solid-primitives/sse,@solid-primitives/state-machine,@solid-primitives/static-store,@solid-primitives/storage,@solid-primitives/styles,@solid-primitives/timer,@solid-primitives/transition-group,@solid-primitives/trigger,@solid-primitives/tween,@solid-primitives/upload,@solid-primitives/vibrate,@solid-primitives/video,@solid-primitives/virtual,@solid-primitives/websocket,@solid-primitives/workers)](https://dash.deno.com/playground/combined-npm-downloads) +[![combined-downloads](https://img.shields.io/endpoint?style=for-the-badge&url=https://combined-npm-downloads.deno.dev/@solid-primitives/a11y,@solid-primitives/active-element,@solid-primitives/analytics,@solid-primitives/async,@solid-primitives/audio,@solid-primitives/bounds,@solid-primitives/broadcast-channel,@solid-primitives/clipboard,@solid-primitives/connectivity,@solid-primitives/context,@solid-primitives/controlled-props,@solid-primitives/controlled-signal,@solid-primitives/cookies,@solid-primitives/cursor,@solid-primitives/date,@solid-primitives/db-store,@solid-primitives/deep,@solid-primitives/destructure,@solid-primitives/devices,@solid-primitives/event-bus,@solid-primitives/event-dispatcher,@solid-primitives/event-listener,@solid-primitives/event-props,@solid-primitives/fetch,@solid-primitives/filesystem,@solid-primitives/flux-store,@solid-primitives/focus,@solid-primitives/form,@solid-primitives/fullscreen,@solid-primitives/geolocation,@solid-primitives/graphql,@solid-primitives/history,@solid-primitives/i18n,@solid-primitives/idle,@solid-primitives/immutable,@solid-primitives/input-mask,@solid-primitives/interaction,@solid-primitives/intersection-observer,@solid-primitives/jsx-tokenizer,@solid-primitives/keyboard,@solid-primitives/keyed,@solid-primitives/lifecycle,@solid-primitives/list,@solid-primitives/list-state,@solid-primitives/map,@solid-primitives/marker,@solid-primitives/masonry,@solid-primitives/match,@solid-primitives/media,@solid-primitives/mediastream,@solid-primitives/memo,@solid-primitives/mouse,@solid-primitives/mutable,@solid-primitives/mutation-observer,@solid-primitives/notification,@solid-primitives/orientation,@solid-primitives/page-utilities,@solid-primitives/pagination,@solid-primitives/permission,@solid-primitives/platform,@solid-primitives/pointer,@solid-primitives/presence,@solid-primitives/promise,@solid-primitives/props,@solid-primitives/queue,@solid-primitives/raf,@solid-primitives/range,@solid-primitives/refs,@solid-primitives/resize-observer,@solid-primitives/resource,@solid-primitives/rootless,@solid-primitives/scheduled,@solid-primitives/script-loader,@solid-primitives/scroll,@solid-primitives/selection,@solid-primitives/sensors,@solid-primitives/set,@solid-primitives/share,@solid-primitives/signal-builders,@solid-primitives/spring,@solid-primitives/sse,@solid-primitives/state-machine,@solid-primitives/static-store,@solid-primitives/storage,@solid-primitives/styles,@solid-primitives/timer,@solid-primitives/transition-group,@solid-primitives/trigger,@solid-primitives/tween,@solid-primitives/upload,@solid-primitives/url,@solid-primitives/vibrate,@solid-primitives/video,@solid-primitives/virtual,@solid-primitives/websocket,@solid-primitives/workers)](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)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](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)|[![SIZE](https://img.shields.io/badge/size-2.25_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/scroll)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/scroll?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/scroll)|✓| |[selection](https://github.com/solidjs-community/solid-primitives/tree/main/packages/selection#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](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)|[![SIZE](https://img.shields.io/badge/size-917_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/selection)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/selection?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/selection)|✓| |

*Utilities*

| -|[analytics](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createAnalytics](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#createanalytics)|[![SIZE](https://img.shields.io/badge/size-125_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/analytics)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/analytics?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/analytics)|| +|[analytics](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeAnalytics](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#makeanalytics)
[createAnalytics](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#createanalytics)
[AnalyticsProvider](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#analyticsprovider)
[useAnalytics](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#useanalytics)
[makeAnalyticsGuard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#makeanalyticsguard)
[createAnalyticsGuard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#createanalyticsguard)
[createServerPlugin](https://github.com/solidjs-community/solid-primitives/tree/main/packages/analytics#createserverplugin)|[![SIZE](https://img.shields.io/badge/size-1.86_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/analytics)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/analytics?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/analytics)|✓| |[controlled-props](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-props#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createControlledProp](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-props#createcontrolledprop)|[![SIZE](https://img.shields.io/badge/size-849_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/controlled-props)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/controlled-props?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/controlled-props)|✓| |[cursor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeBodyCursor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#makebodycursor)
[makeElementCursor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#makeelementcursor)
[createBodyCursor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#createbodycursor)
[createElementCursor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#createelementcursor)
[createDragCursor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#createdragcursor)
[cursorRef](https://github.com/solidjs-community/solid-primitives/tree/main/packages/cursor#cursorref)|[![SIZE](https://img.shields.io/badge/size-651_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/cursor)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/cursor?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/cursor)|✓| |[date](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createDate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createdate)
[createDateNow](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createdatenow)
[createTimeDifference](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createtimedifference)
[createTimeDifferenceFromNow](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createtimedifferencefromnow)
[createTimeAgo](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createtimeago)
[createCountdown](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createcountdown)
[createCountdownFromNow](https://github.com/solidjs-community/solid-primitives/tree/main/packages/date#createcountdownfromnow)|[![SIZE](https://img.shields.io/badge/size-1.51_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/date)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/date?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/date)|✓| @@ -69,7 +69,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n |[share](https://github.com/solidjs-community/solid-primitives/tree/main/packages/share#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSocialShare](https://github.com/solidjs-community/solid-primitives/tree/main/packages/share#createsocialshare)
[createWebShare](https://github.com/solidjs-community/solid-primitives/tree/main/packages/share#createwebshare)
[makeWebShare](https://github.com/solidjs-community/solid-primitives/tree/main/packages/share#makewebshare)|[![SIZE](https://img.shields.io/badge/size-2_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/share)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/share?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/share)|✓| |

*Reactivity*

| |[async](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[fromStream](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#fromstream)
[fromJSONStream](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#fromjsonstream)
[makeAbortable](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#makeabortable)
[createAbortable](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#createabortable)
[makeRetrying](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#makeretrying)
[createAggregated](https://github.com/solidjs-community/solid-primitives/tree/main/packages/async#createaggregated)|[![SIZE](https://img.shields.io/badge/size-1.17_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/async)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/async?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/async)|✓| -|[controlled-signal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createControllableSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablesignal)
[createControllableBooleanSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablebooleansignal)
[createControllableArraySignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablearraysignal)
[createControllableSetSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablesetsignal)|[![SIZE](https://img.shields.io/badge/size-395_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/controlled-signal)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/controlled-signal?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/controlled-signal)|✓| +|[controlled-signal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createControllableSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablesignal)
[createControllableBooleanSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablebooleansignal)
[createControllableArraySignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablearraysignal)
[createControllableSetSignal](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createcontrollablesetsignal)
[createToggleState](https://github.com/solidjs-community/solid-primitives/tree/main/packages/controlled-signal#createtogglestate)|[![SIZE](https://img.shields.io/badge/size-395_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/controlled-signal)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/controlled-signal?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/controlled-signal)|✓| |[db-store](https://github.com/solidjs-community/solid-primitives/tree/main/packages/db-store#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createDbStore](https://github.com/solidjs-community/solid-primitives/tree/main/packages/db-store#createdbstore)
[supabaseAdapter](https://github.com/solidjs-community/solid-primitives/tree/main/packages/db-store#supabaseadapter)|[![SIZE](https://img.shields.io/badge/size-1.42_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/db-store)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/db-store?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/db-store)|| |[deep](https://github.com/solidjs-community/solid-primitives/tree/main/packages/deep#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[trackDeep](https://github.com/solidjs-community/solid-primitives/tree/main/packages/deep#trackdeep)
[trackStore](https://github.com/solidjs-community/solid-primitives/tree/main/packages/deep#trackstore)
[captureStoreUpdates](https://github.com/solidjs-community/solid-primitives/tree/main/packages/deep#capturestoreupdates)|[![SIZE](https://img.shields.io/badge/size-1.3_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/deep)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/deep?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/deep)|✓| |[destructure](https://github.com/solidjs-community/solid-primitives/tree/main/packages/destructure#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[destructure](https://github.com/solidjs-community/solid-primitives/tree/main/packages/destructure#destructure)|[![SIZE](https://img.shields.io/badge/size-498_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/destructure)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/destructure?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/destructure)|✓| @@ -97,7 +97,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n |[resize-observer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/resize-observer#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createResizeObserver](https://github.com/solidjs-community/solid-primitives/tree/main/packages/resize-observer#createresizeobserver)
[createWindowSize](https://github.com/solidjs-community/solid-primitives/tree/main/packages/resize-observer#createwindowsize)
[createElementSize](https://github.com/solidjs-community/solid-primitives/tree/main/packages/resize-observer#createelementsize)|[![SIZE](https://img.shields.io/badge/size-1.46_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/resize-observer)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/resize-observer?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/resize-observer)|✓| |[sensors](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeAccelerometer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makeaccelerometer)
[createAccelerometer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createaccelerometer)
[makeGyroscope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makegyroscope)
[createGyroscope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#creategyroscope)
[makeSensor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makesensor)
[createSensor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createsensor)
[makeCompass](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makecompass)
[createCompass](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createcompass)
[makeBattery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makebattery)
[createBattery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createbattery)|[![SIZE](https://img.shields.io/badge/size-1.2_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/sensors)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/sensors?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/sensors)|✓| |[styles](https://github.com/solidjs-community/solid-primitives/tree/main/packages/styles#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createRemSize](https://github.com/solidjs-community/solid-primitives/tree/main/packages/styles#createremsize)|[![SIZE](https://img.shields.io/badge/size-700_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/styles)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/styles?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/styles)|✓| -|[video](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeVideo](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#makevideo)
[makeVideoPlayer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#makevideoplayer)
[createVideo](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#createvideo)
[createVideoPlayer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#createvideoplayer)|[![SIZE](https://img.shields.io/badge/size-1.64_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/video)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/video?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/video)|✓| +|[video](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeVideo](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#makevideo)
[makeVideoPlayer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#makevideoplayer)
[createVideo](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#createvideo)
[createVideoPlayer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#createvideoplayer)
[makeVideoFrameCallback](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#makevideoframecallback)
[createVideoFrameCallback](https://github.com/solidjs-community/solid-primitives/tree/main/packages/video#createvideoframecallback)|[![SIZE](https://img.shields.io/badge/size-1.64_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/video)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/video?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/video)|✓| |

*Browser APIs*

| |[broadcast-channel](https://github.com/solidjs-community/solid-primitives/tree/main/packages/broadcast-channel#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeBroadcastChannel](https://github.com/solidjs-community/solid-primitives/tree/main/packages/broadcast-channel#makebroadcastchannel)
[createBroadcastChannel](https://github.com/solidjs-community/solid-primitives/tree/main/packages/broadcast-channel#createbroadcastchannel)|[![SIZE](https://img.shields.io/badge/size-569_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/broadcast-channel)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/broadcast-channel?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/broadcast-channel)|✓| |[clipboard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/clipboard#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[copyClipboard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/clipboard#copyclipboard)
[writeClipboard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/clipboard#writeclipboard)
[createClipboard](https://github.com/solidjs-community/solid-primitives/tree/main/packages/clipboard#createclipboard)|[![SIZE](https://img.shields.io/badge/size-920_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/clipboard)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/clipboard?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/clipboard)|✓| @@ -113,6 +113,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n |[storage](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makePersisted](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#makepersisted)
[cookieStorage](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#cookiestorage)
[tauriStorage](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#tauristorage)
[multiplexStorage](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#multiplexstorage)
[storageSync](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#storagesync)
[messageSync](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#messagesync)
[wsSync](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#wssync)
[multiplexSync](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#multiplexsync)
[addClearMethod](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#addclearmethod)
[addWithOptionsMethod](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#addwithoptionsmethod)
[makeObjectStorage](https://github.com/solidjs-community/solid-primitives/tree/main/packages/storage#makeobjectstorage)|[![SIZE](https://img.shields.io/badge/size-2.01_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/storage)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/storage?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/storage)|✓| |[timer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/timer#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeTimer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/timer#maketimer)
[createTimer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/timer#createtimer)
[createTimeoutLoop](https://github.com/solidjs-community/solid-primitives/tree/main/packages/timer#createtimeoutloop)
[createPolled](https://github.com/solidjs-community/solid-primitives/tree/main/packages/timer#createpolled)
[createIntervalCounter](https://github.com/solidjs-community/solid-primitives/tree/main/packages/timer#createintervalcounter)|[![SIZE](https://img.shields.io/badge/size-594_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/timer)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/timer?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/timer)|✓| |[upload](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createFilePicker](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#createfilepicker)
[createFileUploader](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#createfileuploader)
[fileSender](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#filesender)
[fileUploader](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#fileuploader)
[createDropzone](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#createdropzone)
[dropzone](https://github.com/solidjs-community/solid-primitives/tree/main/packages/upload#dropzone)|[![SIZE](https://img.shields.io/badge/size-1.03_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/upload)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/upload?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/upload)|✓| +|[url](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createLocationState](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createlocationstate)
[useSharedLocationState](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#usesharedlocationstate)
[updateLocation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#updatelocation)
[setLocationFallback](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#setlocationfallback)
[createURL](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createurl)
[createURLRecord](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createurlrecord)
[ReactiveURL](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#reactiveurl)
[createSearchParams](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createsearchparams)
[createLocationSearchParams](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#createlocationsearchparams)
[useSharedLocationSearchParams](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#usesharedlocationsearchparams)
[getSearchParamsRecord](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#getsearchparamsrecord)
[ReactiveSearchParams](https://github.com/solidjs-community/solid-primitives/tree/main/packages/url#reactivesearchparams)|[![SIZE](https://img.shields.io/badge/size-2.63_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/url)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/url?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/url)|✓| |[workers](https://github.com/solidjs-community/solid-primitives/tree/main/packages/workers#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createWorker](https://github.com/solidjs-community/solid-primitives/tree/main/packages/workers#createworker)
[createWorkerPool](https://github.com/solidjs-community/solid-primitives/tree/main/packages/workers#createworkerpool)
[createWorkerQuery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/workers#createworkerquery)
[createReactiveWorker](https://github.com/solidjs-community/solid-primitives/tree/main/packages/workers#createreactiveworker)
[workerScope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/workers#workerscope)|[![SIZE](https://img.shields.io/badge/size-2.06_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/workers)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/workers?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/workers)|✓| |

*Network*

| |[connectivity](https://github.com/solidjs-community/solid-primitives/tree/main/packages/connectivity#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](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)|[![SIZE](https://img.shields.io/badge/size-785_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/connectivity)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/connectivity?style=for-the-badge&label=)](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 +

+ +# @solid-primitives/url + +[![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg?style=for-the-badge)](https://lerna.js.org/) +[![version](https://img.shields.io/npm/v/@solid-primitives/url?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/url) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) +[![tested with vitest](https://img.shields.io/badge/tested_with-vitest-6E9F18?style=for-the-badge&logo=vitest)](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':