Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/famous-flowers-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": patch
---

fix(useSearchParams): correctly handle number types
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,40 @@ describe("createSearchParamsSchema", () => {
qux: null,
});
});

it("preserves numeric strings as strings when schema expects string type", () => {
const schema = createSearchParamsSchema({
id: { type: "string", default: "" },
page: { type: "number", default: 1 },
});

// Test that a numeric string stays as string for string type
const result = schema["~standard"].validate({
id: "123",
page: 42,
});

expect("value" in result && result.value).toEqual({
id: "123", // should stay as string
page: 42, // should be number
});
});

it("converts numeric strings to numbers only for number type", () => {
const schema = createSearchParamsSchema({
id: { type: "string", default: "" },
count: { type: "number", default: 0 },
});

// When parsing from URL, numeric strings for number fields should be converted
const result = schema["~standard"].validate({
id: "789", // string field gets numeric string
count: "456", // number field gets numeric string
});

expect("value" in result && result.value).toEqual({
id: "789", // stays as string
count: 456, // converted to number
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ export interface SearchParamsOptions {
* to ensure consistent conversion of URL string values to JavaScript types.
*
* @param searchParams The URLSearchParams object to extract values from
* @param numberFields Optional set of field names that should be treated as numbers
* @returns An object with processed parameter values
* @internal
*/
function extractParamValues(searchParams: URLSearchParams): Record<string, unknown> {
function extractParamValues(
searchParams: URLSearchParams,
numberFields: Set<string> = new Set()
): Record<string, unknown> {
const params: Record<string, unknown> = {};

for (const [key, value] of searchParams.entries()) {
Expand All @@ -107,6 +111,10 @@ function extractParamValues(searchParams: URLSearchParams): Record<string, unkno
else if (value === "true" || value === "false") {
params[key] = value === "true";
}
// Only convert to number if it looks numeric AND the schema expects a number
else if (numberFields.has(key) && value.trim() !== "" && !isNaN(Number(value))) {
params[key] = Number(value);
}
// Handle comma-separated values as arrays (fallback format)
else if (value.includes(",")) {
params[key] = value.split(",");
Expand All @@ -126,22 +134,49 @@ function extractParamValues(searchParams: URLSearchParams): Record<string, unkno
}

/**
* Extract schema keys by validating an empty object and getting the result keys.
* This works with any StandardSchemaV1-compatible schema (Zod, Valibot, Arktype, etc.)
* Schema information extracted from validation
* @internal
*/
interface SchemaInfo {
/** Array of all field names defined in the schema */
keys: string[];
/** Set of field names that expect number types */
numberFields: Set<string>;
/** Default values for all fields */
defaultValues: Record<string, unknown>;
}

/**
* Extract schema information by validating an empty object.
* This consolidates multiple schema validation calls into one for efficiency.
* Works with any StandardSchemaV1-compatible schema (Zod, Valibot, Arktype, etc.)
*
* Note: This function expects schemas used with useSearchParams to have defaults for all fields,
* which is the recommended pattern since URL parameters are inherently optional.
*
* @param schema A StandardSchemaV1-compatible schema
* @returns Array of parameter keys defined in the schema
* @returns Object containing schema keys, number fields, and default values
* @internal
*/
function extractSchemaKeys<Schema extends StandardSchemaV1>(schema: Schema): string[] {
function extractSchemaInfo<Schema extends StandardSchemaV1>(schema: Schema): SchemaInfo {
const validationResult = schema["~standard"].validate({});

if (validationResult && "value" in validationResult) {
const value = validationResult.value as Record<string, unknown>;
return Object.keys(value);
if (!validationResult || !("value" in validationResult)) {
return { keys: [], numberFields: new Set(), defaultValues: {} };
}

return [];
const defaultValues = validationResult.value as Record<string, unknown>;
const keys = Object.keys(defaultValues);
const numberFields = new Set<string>();

// Determine which fields are number types by checking default value types
for (const [key, defaultValue] of Object.entries(defaultValues)) {
if (typeof defaultValue === "number") {
numberFields.add(key);
}
}

return { keys, numberFields, defaultValues };
}

/**
Expand All @@ -151,12 +186,14 @@ function extractSchemaKeys<Schema extends StandardSchemaV1>(schema: Schema): str
*
* @param searchParams The URLSearchParams object to extract values from
* @param schemaKeys Array of parameter keys that are defined in the schema
* @param numberFields Set of field names that should be treated as numbers
* @returns An object with processed parameter values for schema-defined keys only
* @internal
*/
function extractSelectiveParamValues(
searchParams: URLSearchParams,
schemaKeys: string[]
schemaKeys: string[],
numberFields: Set<string> = new Set()
): Record<string, unknown> {
const params: Record<string, unknown> = {};

Expand Down Expand Up @@ -186,7 +223,11 @@ function extractSelectiveParamValues(
else if (value === "true" || value === "false") {
params[key] = value === "true";
}

// Only convert to number if it looks numeric AND the schema expects a number
// This handles cases like ?page=2 or ?price=19.99
else if (numberFields.has(key) && value.trim() !== "" && !isNaN(Number(value))) {
params[key] = Number(value);
}
// Handle comma-separated values as arrays (fallback format)
else if (value.includes(",")) {
params[key] = value.split(",");
Expand Down Expand Up @@ -305,6 +346,12 @@ class SearchParams<Schema extends StandardSchemaV1> {
*/
#defaultValues: Record<string, unknown>;

/**
* Set of field names that expect number types based on schema validation
* Used to intelligently convert URL string values to numbers only when appropriate
*/
#numberFields: Set<string>;

/**
* Timer ID for debouncing URL updates
* @private
Expand Down Expand Up @@ -336,27 +383,21 @@ class SearchParams<Schema extends StandardSchemaV1> {
...options,
};

// Extract schema shape and default values by validating an empty object
const validationResult = this.validate({});
// Extract schema information (keys, number fields, defaults) in one pass
const schemaInfo = extractSchemaInfo(schema);

if (validationResult && "value" in validationResult) {
const value = validationResult.value as Record<string, unknown>;

// Store schema shape for property checking
this.#schemaShape = Object.keys(value).reduce(
(acc, key) => {
acc[key] = true;
return acc;
},
{} as Record<string, true>
);
// Store schema shape for property checking
this.#schemaShape = schemaInfo.keys.reduce(
(acc, key) => {
acc[key] = true;
return acc;
},
{} as Record<string, true>
);

// Store default values for comparing later
this.#defaultValues = { ...value };
} else {
this.#schemaShape = {};
this.#defaultValues = {};
}
// Store default values and number fields
this.#defaultValues = { ...schemaInfo.defaultValues };
this.#numberFields = schemaInfo.numberFields;
}

/**
Expand Down Expand Up @@ -765,8 +806,8 @@ class SearchParams<Schema extends StandardSchemaV1> {
return {};
}

// If not using compression, use the normal extraction
return extractParamValues(searchParams);
// If not using compression, use the normal extraction with number field detection
return extractParamValues(searchParams, this.#numberFields);
}

/**
Expand Down Expand Up @@ -1196,8 +1237,12 @@ export function validateSearchParams<Schema extends StandardSchemaV1>(
}
} else {
// Normal (uncompressed) extraction - use selective extraction for fine-grained reactivity
const schemaKeys = extractSchemaKeys(schema);
const paramsObject = extractSelectiveParamValues(url.searchParams, schemaKeys);
const schemaInfo = extractSchemaInfo(schema);
const paramsObject = extractSelectiveParamValues(
url.searchParams,
schemaInfo.keys,
schemaInfo.numberFields
);

// Validate the parameters against the schema
let result = schema["~standard"].validate(paramsObject);
Expand Down Expand Up @@ -1379,7 +1424,8 @@ export function useSearchParams<Schema extends StandardSchemaV1>(

// Remove incorrect params on initialization (only after hydration)
if (options.updateURL !== false) {
const currentParams = extractParamValues(page.url.searchParams);
const schemaInfo = extractSchemaInfo(schema);
const currentParams = extractParamValues(page.url.searchParams, schemaInfo.numberFields);
const validationResult = schema["~standard"].validate(currentParams);
if (
validationResult &&
Expand All @@ -1405,25 +1451,24 @@ export function useSearchParams<Schema extends StandardSchemaV1>(

// If showDefaults is true, we need to initialize the URL with all default values (only after hydration)
if (options.showDefaults) {
// Get all the default values (by validating an empty object)
const validationResult = schema["~standard"].validate({});
// Get all the schema information in one pass
const schemaInfo = extractSchemaInfo(schema);

if (validationResult && "value" in validationResult) {
if (schemaInfo.keys.length > 0) {
// If compression is enabled, use SearchParams.update() method which handles compression
if (options.compress) {
// Call the update method with the default values to properly handle compression
searchParams.update(
validationResult.value as Partial<StandardSchemaV1.InferOutput<Schema>>
schemaInfo.defaultValues as Partial<StandardSchemaV1.InferOutput<Schema>>
);
} else {
// For non-compressed mode, use the original approach
const defaultValues = validationResult.value as Record<string, unknown>;
const currentParams = extractParamValues(page.url.searchParams);
const currentParams = extractParamValues(page.url.searchParams, schemaInfo.numberFields);
const newSearchParams = new URLSearchParams(page.url.searchParams.toString());
let needsUpdate = false;

// For each default value, add it to the URL if not already present
for (const [key, defaultValue] of Object.entries(defaultValues)) {
for (const [key, defaultValue] of Object.entries(schemaInfo.defaultValues)) {
// Skip if the parameter is already in the URL (don't override user values)
if (key in currentParams) continue;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,55 @@ test.describe("useSearchParams scenarios", () => {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const scrollY = await page.evaluate(() => window.scrollY);

// Trigger a parameter change
await page.getByTestId("inc").click();
await page.waitForTimeout(100);

// Check scroll position is maintained
const newScrollY = await page.evaluate(() => window.scrollY);
expect(newScrollY).toBe(scrollY);
});
}

// Test for issue #320: number params should parse from URL correctly
// Skip for memory mode since it doesn't read from URL
if (!s.memory) {
test("number params from URL are parsed as numbers", async ({ page }) => {
// Navigate to URL with number parameter
await page.goto(`${s.route}?page=42`);
await page.waitForTimeout(300);

// Verify the number is parsed correctly
await expect(pageCount(page)).toHaveText("42");

// Verify URL contains the number
// Note: compress mode doesn't compress on navigation, only on set
if (s.compress) {
// When navigating to uncompressed params, they stay uncompressed
await expect(page).toHaveURL(/page=42/);
} else {
await expect(page).toHaveURL(/page=42/);
}
});

test("negative numbers are parsed correctly", async ({ page }) => {
await page.goto(`${s.route}?page=-5`);
await page.waitForTimeout(300);
await expect(pageCount(page)).toHaveText("-5");
});

test("decimal numbers are parsed correctly", async ({ page }) => {
await page.goto(`${s.route}?page=3.14`);
await page.waitForTimeout(300);
await expect(pageCount(page)).toHaveText("3.14");
});

test("zero is parsed as number not string", async ({ page }) => {
await page.goto(`${s.route}?page=0`);
await page.waitForTimeout(300);
await expect(pageCount(page)).toHaveText("0");
});
}
});
}
});
Loading