tanstack-url-state 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ function SettingsRoute() {
44
44
  - **Optimistic:** every hook reading a key sees the new value at once, before the URL changes.
45
45
  - **Batched:** updates in the same tick (or inside a `debounce` window) become one navigation.
46
46
  - **Removal:** `null`, `undefined`, `""` and `[]` remove the key, which then reads as its default.
47
- - **Setters return a promise** that resolves once the URL holds the update.
47
+ - **Setters return a promise** that resolves once the URL holds the update, or rejects with the router's error if the navigation fails (the state then reads the URL again).
48
48
 
49
49
  Options, per hook or per call:
50
50
 
@@ -54,8 +54,28 @@ Options, per hook or per call:
54
54
  | `scroll` | `false` | Scroll to the top after the update. |
55
55
  | `debounce` | `0` | Milliseconds to wait before writing the URL; the state updates immediately. |
56
56
 
57
+ ## Lists: pagination and sort
58
+
59
+ ```tsx
60
+ import { pagination, searchParams, sortParam } from "tanstack-url-state";
61
+
62
+ export const Route = createFileRoute("/videos")({
63
+ ...searchParams({
64
+ ...pagination(), // page (default 1), perPage (default 20, at most 100)
65
+ sort: sortParam(["title", "publishDate"], "-publishDate"), // "title" | "-title" | …
66
+ }),
67
+ });
68
+ ```
69
+
70
+ - `pagination({ perPage, maxPerPage })` gives `page` and `perPage` with defaults (kept out of the URL) and a fallback for junk such as `?page=abc`.
71
+ - `sortParam(sorts, fallback?)` accepts `field` and `-field` (descending) for every field in `sorts`, which may list bare or signed names; anything else reads as `fallback`, or as no sort.
72
+
57
73
  `searchDefaults(schema)` returns the defaulted keys of a zod object schema, if you need them elsewhere.
58
74
 
75
+ ## Bundle size
76
+
77
+ ESM only, one module per concern, `"sideEffects": false`: you ship what you import. The schema helpers (`pagination`, `sortParam`, `searchDefaults`) need only `zod`; `searchParams` adds the router's `stripSearchParams`; the hooks need React and the router and never pull the route helpers. A test bundles each group to keep it that way.
78
+
59
79
  ## License
60
80
 
61
81
  MIT
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- export { searchDefaults, searchParams } from "./search-params.js";
1
+ export { pagination, searchDefaults, sortParam } from "./schema.js";
2
+ export type { PaginationOptions, SortValue } from "./schema.js";
3
+ export { searchParams } from "./search-params.js";
2
4
  export { useSearchState, useSearchStates } from "./use-search-state.js";
3
5
  export type { SearchRoute, SearchStateOptions } from "./use-search-state.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export { searchDefaults, searchParams } from "./search-params.js";
1
+ export { pagination, searchDefaults, sortParam } from "./schema.js";
2
+ export { searchParams } from "./search-params.js";
2
3
  export { useSearchState, useSearchStates } from "./use-search-state.js";
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ /** Values a route search schema falls back to when a key is missing from the URL. */
3
+ export declare function searchDefaults(schema: unknown): Record<string, unknown>;
4
+ export interface PaginationOptions {
5
+ /** Default page size, kept out of the URL; default 20. */
6
+ perPage?: number;
7
+ /** Largest page size a URL may ask for; default 100. */
8
+ maxPerPage?: number;
9
+ }
10
+ /**
11
+ * `page` (1-based) and `perPage` for a list route: spread into `searchParams({ ... })`.
12
+ * Both have defaults (so they stay out of the URL) and fall back to them on junk input.
13
+ */
14
+ export declare function pagination({ perPage, maxPerPage }?: PaginationOptions): {
15
+ page: z.ZodCatch<z.ZodDefault<z.ZodNumber>>;
16
+ perPage: z.ZodCatch<z.ZodDefault<z.ZodNumber>>;
17
+ };
18
+ /** `field` and `-field` for every field, whichever form `sorts` lists them in. */
19
+ export type SortValue<S extends string> = S extends `-${infer F}` ? S | F : S | `-${S}`;
20
+ /**
21
+ * `sort=field` / `sort=-field` (descending). Unknown fields fall back to `fallback`, or to no
22
+ * sort when there is none. `sorts` may list bare fields, signed ones, or both.
23
+ */
24
+ export declare function sortParam<const S extends string>(sorts: readonly S[]): z.ZodCatch<z.ZodOptional<z.ZodEnum<{
25
+ [K in SortValue<S>]: K;
26
+ }>>>;
27
+ export declare function sortParam<const S extends string>(sorts: readonly S[], fallback: SortValue<S>): z.ZodCatch<z.ZodDefault<z.ZodEnum<{
28
+ [K in SortValue<S>]: K;
29
+ }>>>;
package/dist/schema.js ADDED
@@ -0,0 +1,33 @@
1
+ // zod-only building blocks; no router or React import, so they bundle on their own.
2
+ import { z } from "zod";
3
+ const defaultsCache = new WeakMap();
4
+ /** Values a route search schema falls back to when a key is missing from the URL. */
5
+ export function searchDefaults(schema) {
6
+ if (!(schema instanceof z.ZodObject)) {
7
+ return {};
8
+ }
9
+ let defaults = defaultsCache.get(schema);
10
+ if (!defaults) {
11
+ // zod 4 `.partial()` keeps `.default()`s, so this yields exactly the defaulted keys.
12
+ defaults = Object.fromEntries(Object.entries(schema.partial().parse({})).filter(([, value]) => value !== undefined));
13
+ defaultsCache.set(schema, defaults);
14
+ }
15
+ return defaults;
16
+ }
17
+ /**
18
+ * `page` (1-based) and `perPage` for a list route: spread into `searchParams({ ... })`.
19
+ * Both have defaults (so they stay out of the URL) and fall back to them on junk input.
20
+ */
21
+ export function pagination({ perPage = 20, maxPerPage = 100 } = {}) {
22
+ return {
23
+ page: z.number().int().positive().default(1).catch(1),
24
+ perPage: z.number().int().min(1).max(maxPerPage).default(perPage).catch(perPage),
25
+ };
26
+ }
27
+ export function sortParam(sorts, fallback) {
28
+ const fields = [...new Set(sorts.map((sort) => sort.replace(/^-/, "")))];
29
+ const sort = z.enum(fields.flatMap((field) => [field, `-${field}`]));
30
+ return fallback === undefined
31
+ ? sort.optional().catch(undefined)
32
+ : sort.default(fallback).catch(fallback);
33
+ }
@@ -1,6 +1,4 @@
1
1
  import { z } from "zod";
2
- /** Values a route search schema falls back to when a key is missing from the URL. */
3
- export declare function searchDefaults(schema: unknown): Record<string, unknown>;
4
2
  /**
5
3
  * Route search options from a zod shape: `validateSearch` (parsing + types) and a middleware
6
4
  * that drops values equal to their default from the URL. Spread into `createFileRoute(...)({ ... })`.
@@ -1,19 +1,6 @@
1
1
  import { stripSearchParams } from "@tanstack/react-router";
2
2
  import { z } from "zod";
3
- const defaultsCache = new WeakMap();
4
- /** Values a route search schema falls back to when a key is missing from the URL. */
5
- export function searchDefaults(schema) {
6
- if (!(schema instanceof z.ZodObject)) {
7
- return {};
8
- }
9
- let defaults = defaultsCache.get(schema);
10
- if (!defaults) {
11
- // zod 4 `.partial()` keeps `.default()`s, so this yields exactly the defaulted keys.
12
- defaults = Object.fromEntries(Object.entries(schema.partial().parse({})).filter(([, value]) => value !== undefined));
13
- defaultsCache.set(schema, defaults);
14
- }
15
- return defaults;
16
- }
3
+ import { searchDefaults } from "./schema.js";
17
4
  /**
18
5
  * Route search options from a zod shape: `validateSearch` (parsing + types) and a middleware
19
6
  * that drops values equal to their default from the URL. Spread into `createFileRoute(...)({ ... })`.
@@ -1,13 +1,13 @@
1
1
  import { useRouter, useSearch } from "@tanstack/react-router";
2
2
  import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
3
- import { searchDefaults } from "./search-params.js";
3
+ import { searchDefaults } from "./schema.js";
4
4
  // One queue per app: search params are global to the URL. Only written by client events, never during SSR.
5
5
  const EMPTY = {};
6
6
  let pending = EMPTY;
7
7
  let queued = null;
8
8
  let timer;
9
9
  let nextFlush = null;
10
- let resolveFlush;
10
+ let settleFlush;
11
11
  const listeners = new Set();
12
12
  const subscribe = (listener) => {
13
13
  listeners.add(listener);
@@ -21,13 +21,15 @@ const emit = () => {
21
21
  }
22
22
  };
23
23
  const isEmpty = (value) => value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
24
+ // Runs from a timer, so it never throws: a failed navigation rejects the setters' promise instead.
24
25
  async function flush() {
25
26
  timer = undefined;
26
27
  const sent = pending;
27
28
  const job = queued;
28
- const resolve = resolveFlush;
29
+ const settle = settleFlush;
29
30
  queued = null;
30
31
  nextFlush = null;
32
+ let failure;
31
33
  try {
32
34
  if (job) {
33
35
  const patch = Object.fromEntries(Object.entries(sent).map(([key, value]) => [key, isEmpty(value) ? undefined : value]));
@@ -39,12 +41,21 @@ async function flush() {
39
41
  });
40
42
  }
41
43
  }
44
+ catch (error) {
45
+ failure = { error };
46
+ }
42
47
  finally {
43
- // The URL now owns what was sent; keep only values set again while navigating.
48
+ // The URL now owns what was sent (or kept its old values if navigating failed);
49
+ // keep only values set again while navigating.
44
50
  const rest = Object.fromEntries(Object.entries(pending).filter(([key, value]) => sent[key] !== value));
45
51
  pending = Object.keys(rest).length > 0 ? rest : EMPTY;
46
52
  emit();
47
- resolve?.();
53
+ if (failure) {
54
+ settle?.reject(failure.error);
55
+ }
56
+ else {
57
+ settle?.resolve();
58
+ }
48
59
  }
49
60
  }
50
61
  function enqueue(router, patch, options) {
@@ -54,8 +65,8 @@ function enqueue(router, patch, options) {
54
65
  push: queued?.push === true || options.history === "push",
55
66
  scroll: queued?.scroll === true || options.scroll === true,
56
67
  };
57
- nextFlush ??= new Promise((resolve) => {
58
- resolveFlush = resolve;
68
+ nextFlush ??= new Promise((resolve, reject) => {
69
+ settleFlush = { resolve, reject };
59
70
  });
60
71
  emit();
61
72
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tanstack-url-state",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "nuqs-style URL search state for TanStack Router, typed by each route's validateSearch",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,14 +8,22 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/B0redDev/tanstack-url-state.git"
10
10
  },
11
- "keywords": ["tanstack-router", "search-params", "url-state", "react", "zod"],
11
+ "keywords": [
12
+ "tanstack-router",
13
+ "search-params",
14
+ "url-state",
15
+ "react",
16
+ "zod"
17
+ ],
12
18
  "exports": {
13
19
  ".": {
14
20
  "types": "./dist/index.d.ts",
15
21
  "import": "./dist/index.js"
16
22
  }
17
23
  },
18
- "files": ["dist"],
24
+ "files": [
25
+ "dist"
26
+ ],
19
27
  "sideEffects": false,
20
28
  "scripts": {
21
29
  "build": "rm -rf dist && tsc -p tsconfig.build.json",