diff --git a/.changeset/eighty-donuts-deny.md b/.changeset/eighty-donuts-deny.md new file mode 100644 index 00000000..d86c7fda --- /dev/null +++ b/.changeset/eighty-donuts-deny.md @@ -0,0 +1,5 @@ +--- +"runed": patch +--- + +feat(useSearchParams): Date support diff --git a/.changeset/shaggy-laws-help.md b/.changeset/shaggy-laws-help.md new file mode 100644 index 00000000..418f2ae8 --- /dev/null +++ b/.changeset/shaggy-laws-help.md @@ -0,0 +1,5 @@ +--- +"runed": patch +--- + +feat(useSearchParams): Zod codec support diff --git a/packages/runed/package.json b/packages/runed/package.json index e083c1bc..c9d06dd9 100644 --- a/packages/runed/package.json +++ b/packages/runed/package.json @@ -66,11 +66,15 @@ ], "peerDependencies": { "svelte": "^5.7.0", - "@sveltejs/kit": "^2.21.0" + "@sveltejs/kit": "^2.21.0", + "zod": "^4.1.0" }, "peerDependenciesMeta": { "@sveltejs/kit": { "optional": true + }, + "zod": { + "optional": true } }, "devDependencies": { @@ -96,7 +100,8 @@ "tslib": "^2.4.1", "typescript": "catalog:", "vite": "catalog:", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "zod": "^4.1.0" }, "dependencies": { "dequal": "^2.0.3", diff --git a/packages/runed/src/lib/utilities/use-search-params/create-search-params-schema.test.ts b/packages/runed/src/lib/utilities/use-search-params/create-search-params-schema.test.ts index 4c1f948d..56b20fc2 100644 --- a/packages/runed/src/lib/utilities/use-search-params/create-search-params-schema.test.ts +++ b/packages/runed/src/lib/utilities/use-search-params/create-search-params-schema.test.ts @@ -4,10 +4,12 @@ import type { StandardSchemaV1 } from "./use-search-params.svelte.js"; describe("createSearchParamsSchema", () => { it("applies default values for all schema types", () => { + const testDate = new Date("2023-01-01T00:00:00Z"); const schema = createSearchParamsSchema({ str: { type: "string", default: "hello" }, num: { type: "number", default: 2 }, bool: { type: "boolean", default: true }, + date: { type: "date", default: testDate }, arr: { type: "array", default: [1, 2], arrayType: 1 }, obj: { type: "object", default: { a: "b" }, objectType: { a: "" } }, }); @@ -16,6 +18,7 @@ describe("createSearchParamsSchema", () => { str: "hello", num: 2, bool: true, + date: testDate, arr: [1, 2], obj: { a: "b" }, }); @@ -26,13 +29,16 @@ describe("createSearchParamsSchema", () => { str: { type: "string", default: "" }, num: { type: "number", default: 0 }, bool: { type: "boolean", default: false }, + date: { type: "date", default: new Date() }, arr: { type: "array", default: [], arrayType: 1 }, obj: { type: "object", default: {}, objectType: { a: "" } }, }); + const testDate = new Date("2023-06-15T10:30:00.000Z"); const input = { str: 123, num: "5", bool: "true", + date: testDate, arr: [3, 4], obj: { c: 4 }, } as unknown; @@ -41,17 +47,19 @@ describe("createSearchParamsSchema", () => { str: "123", num: 5, bool: true, + date: testDate, arr: [3, 4], obj: { c: 4 }, }); }); - it("returns issues for invalid number, array, and object inputs", () => { + it("returns issues for invalid number, array, date, and object inputs", () => { const schema = createSearchParamsSchema({ foo: { type: "string" }, bar: { type: "number" }, baz: { type: "array" }, qux: { type: "object" }, + date: { type: "date" }, }); // Invalid input @@ -60,6 +68,7 @@ describe("createSearchParamsSchema", () => { bar: "not-a-number", baz: "not-array", qux: "not-object", + date: "invalid-date-string", }) as StandardSchemaV1.FailureResult; expect("issues" in invalidResult).toBe(true); @@ -78,6 +87,10 @@ describe("createSearchParamsSchema", () => { message: expect.stringContaining("Invalid object"), path: ["qux"], }), + expect.objectContaining({ + message: expect.stringContaining("Invalid date"), + path: ["date"], + }), ]) ); }); @@ -104,6 +117,7 @@ describe("createSearchParamsSchema", () => { bar: { type: "number" }, baz: { type: "array" }, qux: { type: "object" }, + date: { type: "date" }, }); const emptyResult = schema["~standard"].validate({}); expect("value" in emptyResult && emptyResult.value).toEqual({ @@ -111,6 +125,7 @@ describe("createSearchParamsSchema", () => { bar: null, baz: null, qux: null, + date: null, }); }); @@ -149,4 +164,98 @@ describe("createSearchParamsSchema", () => { count: 456, // converted to number }); }); + + describe("Date type support", () => { + it("validates Date objects correctly", () => { + const schema = createSearchParamsSchema({ + startDate: { type: "date", default: new Date("2023-01-01T00:00:00Z") }, + endDate: { type: "date", default: new Date("2023-12-31T23:59:59Z") }, + }); + + // Test with Date objects + const result = schema["~standard"].validate({ + startDate: new Date("2023-06-15T10:30:00Z"), + endDate: new Date("2023-06-20T18:00:00Z"), + }); + + expect(result).toHaveProperty("value"); + if ("value" in result) { + expect(result.value.startDate).toBeInstanceOf(Date); + expect(result.value.endDate).toBeInstanceOf(Date); + expect(result.value.startDate.toISOString()).toBe("2023-06-15T10:30:00.000Z"); + expect(result.value.endDate.toISOString()).toBe("2023-06-20T18:00:00.000Z"); + } + }); + + it("stores dateFormat metadata in schema", () => { + const schema = createSearchParamsSchema({ + birthDate: { type: "date", default: new Date("1990-01-15T00:00:00Z"), dateFormat: "date" }, + createdAt: { + type: "date", + default: new Date("2023-01-01T00:00:00Z"), + dateFormat: "datetime", + }, + updatedAt: { type: "date", default: new Date("2023-01-01T00:00:00Z") }, // no format specified + }); + + // Check that dateFormat metadata is stored + const schemaWithMetadata = schema as typeof schema & { + __dateFormats?: Record; + }; + expect(schemaWithMetadata.__dateFormats).toBeDefined(); + expect(schemaWithMetadata.__dateFormats?.birthDate).toBe("date"); + expect(schemaWithMetadata.__dateFormats?.createdAt).toBe("datetime"); + expect(schemaWithMetadata.__dateFormats?.updatedAt).toBeUndefined(); // not specified + }); + + it("validates ISO8601 strings correctly (simulating URL parameters)", () => { + const schema = createSearchParamsSchema({ + startDate: { type: "date", default: new Date("2023-01-01T00:00:00Z") }, + }); + + // Test with ISO8601 string (simulating URL parameter) + const result = schema["~standard"].validate({ + startDate: "2023-06-15T10:30:00.000Z", + }); + + expect(result).toHaveProperty("value"); + if ("value" in result) { + expect(result.value.startDate).toBeInstanceOf(Date); + expect(result.value.startDate.toISOString()).toBe("2023-06-15T10:30:00.000Z"); + } + }); + + it("rejects invalid date strings", () => { + const schema = createSearchParamsSchema({ + startDate: { type: "date", default: new Date("2023-01-01T00:00:00Z") }, + }); + + // Test with invalid date string + const result = schema["~standard"].validate({ + startDate: "invalid-date-string", + }); + + expect(result).toHaveProperty("issues"); + if ("issues" in result && result.issues) { + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues[0]?.message).toContain("Invalid date"); + } + }); + + it("uses default Date values when no input provided", () => { + const defaultDate = new Date("2023-01-01T00:00:00Z"); + const schema = createSearchParamsSchema({ + startDate: { type: "date", default: defaultDate }, + }); + + // Test with empty object (should use defaults) + const result = schema["~standard"].validate({}); + + expect(result).toHaveProperty("value"); + if ("value" in result) { + expect(result.value.startDate).toBeInstanceOf(Date); + expect(result.value.startDate.toISOString()).toBe(defaultDate.toISOString()); + } + }); + }); }); diff --git a/packages/runed/src/lib/utilities/use-search-params/extract-param-values.test.ts b/packages/runed/src/lib/utilities/use-search-params/extract-param-values.test.ts index 8a59ec8e..cb38f7d1 100644 --- a/packages/runed/src/lib/utilities/use-search-params/extract-param-values.test.ts +++ b/packages/runed/src/lib/utilities/use-search-params/extract-param-values.test.ts @@ -14,7 +14,7 @@ describe("extractParamValues", () => { it("converts numeric strings to numbers when field is in numberFields", () => { const params = new URLSearchParams("page=5&limit=10&code=123"); const numberFields = new Set(["page", "limit"]); - const result = extractParamValues(params, numberFields); + const result = extractParamValues(params, { numberFields }); expect(result.page).toBe(5); expect(typeof result.page).toBe("number"); @@ -27,7 +27,7 @@ describe("extractParamValues", () => { it("handles negative numbers", () => { const params = new URLSearchParams("temperature=-5"); const numberFields = new Set(["temperature"]); - const result = extractParamValues(params, numberFields); + const result = extractParamValues(params, { numberFields }); expect(result.temperature).toBe(-5); }); @@ -35,7 +35,7 @@ describe("extractParamValues", () => { it("handles decimal numbers", () => { const params = new URLSearchParams("price=19.99"); const numberFields = new Set(["price"]); - const result = extractParamValues(params, numberFields); + const result = extractParamValues(params, { numberFields }); expect(result.price).toBe(19.99); }); @@ -43,7 +43,7 @@ describe("extractParamValues", () => { it("keeps strings as strings when not in numberFields", () => { const params = new URLSearchParams("name=John&id=12345"); const numberFields = new Set([]); - const result = extractParamValues(params, numberFields); + const result = extractParamValues(params, { numberFields }); expect(result.name).toBe("John"); expect(typeof result.name).toBe("string"); @@ -125,7 +125,7 @@ describe("extractParamValues", () => { it("should split comma-separated values ONLY for array fields (currently fails)", () => { const params = new URLSearchParams("name=Smith, John&tags=tag1,tag2,tag3"); - const result = extractParamValues(params, new Set(), new Set(["tags"])); + const result = extractParamValues(params, { arrayFields: new Set(["tags"]) }); expect(result.name).toBe("Smith, John"); expect(typeof result.name).toBe("string"); @@ -134,7 +134,7 @@ describe("extractParamValues", () => { it("should NOT treat CSV-like data as array by default", () => { const params = new URLSearchParams("data=column1,column2,column3"); - const result = extractParamValues(params, new Set(), new Set()); + const result = extractParamValues(params); expect(result.data).toBe("column1,column2,column3"); expect(typeof result.data).toBe("string"); @@ -200,7 +200,7 @@ describe("extractParamValues", () => { it("processes multiple different types correctly", () => { const params = new URLSearchParams('page=5&active=true&tags=["a","b"]&name=John Doe&empty='); const numberFields = new Set(["page"]); - const result = extractParamValues(params, numberFields); + const result = extractParamValues(params, { numberFields }); expect(result.page).toBe(5); expect(result.active).toBe(true); @@ -212,7 +212,7 @@ describe("extractParamValues", () => { it("name with comma should NOT be split even when number field specified", () => { const params = new URLSearchParams("page=3&name=Smith, John"); const numberFields = new Set(["page"]); - const result = extractParamValues(params, numberFields); + const result = extractParamValues(params, { numberFields }); expect(result.page).toBe(3); // Expected: name should remain as string @@ -234,7 +234,7 @@ describe("extractParamValues", () => { it("splits comma-separated values ONLY when field is marked as array", () => { const params = new URLSearchParams("tags=red,%20blue,%20%20green"); const arrayFields = new Set(["tags"]); - const result = extractParamValues(params, new Set(), arrayFields); + const result = extractParamValues(params, { arrayFields }); expect(result.tags).toEqual(["red", " blue", " green"]); expect(Array.isArray(result.tags)).toBe(true); @@ -243,7 +243,7 @@ describe("extractParamValues", () => { it("splits numeric comma-separated values into array only for array fields", () => { const params = new URLSearchParams("ids=1,2,3,4,5"); const arrayFields = new Set(["ids"]); - const result = extractParamValues(params, new Set(), arrayFields); + const result = extractParamValues(params, { arrayFields }); expect(result.ids).toEqual(["1", "2", "3", "4", "5"]); expect(Array.isArray(result.ids)).toBe(true); diff --git a/packages/runed/src/lib/utilities/use-search-params/use-search-params.scenarios.spec.ts b/packages/runed/src/lib/utilities/use-search-params/use-search-params.scenarios.spec.ts index a8de64ea..02d1abc4 100644 --- a/packages/runed/src/lib/utilities/use-search-params/use-search-params.scenarios.spec.ts +++ b/packages/runed/src/lib/utilities/use-search-params/use-search-params.scenarios.spec.ts @@ -41,6 +41,8 @@ const scenarios: Scenario[] = [ // Consistent helper functions using getByTestId const pageCount = (page: Page) => page.getByTestId("page"); const filterText = (page: Page) => page.getByTestId("filter"); +const createdAtText = (page: Page) => page.getByTestId("createdAt"); +const updatedAtText = (page: Page) => page.getByTestId("updatedAt"); function getExpectedURL(s: Scenario, action: "mount" | "inc" | "reset"): string | RegExp { if (action === "mount") { @@ -488,7 +490,292 @@ test.describe("useSearchParams scenarios", () => { await page.waitForTimeout(300); await expect(pageCount(page)).toHaveText("0"); }); + + test("date params from URL are parsed as Date objects", async ({ page }) => { + // Navigate to URL with date parameter + await page.goto(`${s.route}?createdAt=2023-06-15T10:30:00.000Z`); + await page.waitForTimeout(300); + + // Verify the date is parsed and displayed correctly + await expect(createdAtText(page)).toHaveText("2023-06-15T10:30:00.000Z"); + + // Verify URL contains the date + // 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(/createdAt=2023-06-15T10:30:00\.000Z/); + } else if (s.showDefaults) { + // Show-defaults encodes the URL and includes default params + await expect(page).toHaveURL(/createdAt=2023-06-15T10%3A30%3A00\.000Z/); + } else { + await expect(page).toHaveURL(/createdAt=2023-06-15T10:30:00\.000Z/); + } + }); } + + test("date parameter updates correctly", async ({ page }) => { + await page.getByTestId("setCreatedAt").click(); + + // createdAt has dateFormat: "date" in schema, so it's serialized as date-only + // and becomes UTC midnight when read back (consistent in all modes) + await expect(createdAtText(page)).toHaveText("2023-06-15T00:00:00.000Z"); + + if (s.debounce) { + await page.waitForTimeout(250); + } + + // URL should be updated + if (s.memory) { + await expect(page).toHaveURL(s.route); + } else if (s.compress) { + await expect(page.url()).toMatch(/_data=/); + const url = new URL(page.url()); + const data = url.searchParams.get("_data")!; + const decompressed = decompress(data)!; + const obj = JSON.parse(decompressed); + // Compressed data now respects dateFormat and stores date-only format + expect(obj.createdAt).toBe("2023-06-15"); + } else { + // createdAt uses date-only format from schema property + await expect(page).toHaveURL(/createdAt=2023-06-15/); + await expect(page).not.toHaveURL(/createdAt=2023-06-15T/); + } + }); }); } + + // Test date format configuration (schema property and options) + test.describe("date-format-schema", () => { + const route = "/test-search/default"; + + test.beforeEach(async ({ page }) => { + await page.goto(route); + await page.waitForTimeout(300); + }); + + test("createdAt uses date-only format from schema property", async ({ page }) => { + const setCreatedAtButton = page.getByTestId("setCreatedAt"); + await setCreatedAtButton.waitFor({ state: "visible" }); + await setCreatedAtButton.click({ force: true }); + await page.waitForTimeout(300); + + // When date is serialized as date-only (2023-06-15) and read back, + // it becomes UTC midnight (2023-06-15T00:00:00.000Z) + await expect(createdAtText(page)).toHaveText("2023-06-15T00:00:00.000Z"); + + // URL should use date-only format (YYYY-MM-DD) because schema has dateFormat: "date" + await expect(page).toHaveURL(/createdAt=2023-06-15/); + await expect(page).not.toHaveURL(/createdAt=2023-06-15T/); + }); + + test("updatedAt uses datetime format by default", async ({ page }) => { + const setUpdatedAtButton = page.getByTestId("setUpdatedAt"); + await setUpdatedAtButton.waitFor({ state: "visible" }); + await setUpdatedAtButton.click({ force: true }); + await page.waitForTimeout(300); + + // Display should show full ISO string + await expect(updatedAtText(page)).toHaveText("2023-06-20T18:00:00.000Z"); + + // URL should use full datetime format (ISO8601) by default + await expect(page).toHaveURL(/updatedAt=2023-06-20T18%3A00%3A00\.000Z/); + }); + + test("date-only format from URL is parsed correctly", async ({ page }) => { + // Navigate with date-only format + await page.goto(`${route}?createdAt=2023-06-15`); + await page.waitForTimeout(300); + + // Should parse as UTC midnight and display full ISO string + await expect(createdAtText(page)).toHaveText("2023-06-15T00:00:00.000Z"); + + // URL should preserve date-only format + await expect(page).toHaveURL(/createdAt=2023-06-15/); + await expect(page).not.toHaveURL(/createdAt=2023-06-15T/); + }); + }); + + test.describe("date-format-options", () => { + const route = "/test-search/date-format-options"; + + test.beforeEach(async ({ page }) => { + await page.goto(route); + await page.waitForTimeout(300); + }); + + test("dateFormats option overrides schema property for createdAt", async ({ page }) => { + const setCreatedAtButton = page.getByTestId("setCreatedAt"); + await setCreatedAtButton.waitFor({ state: "visible" }); + await setCreatedAtButton.click({ force: true }); + await page.waitForTimeout(300); + + // When date is serialized as date-only (2023-06-15) and read back, + // it becomes UTC midnight (2023-06-15T00:00:00.000Z) + await expect(createdAtText(page)).toHaveText("2023-06-15T00:00:00.000Z"); + + // URL should use date-only format from options (overriding schema) + await expect(page).toHaveURL(/createdAt=2023-06-15/); + await expect(page).not.toHaveURL(/createdAt=2023-06-15T/); + }); + + test("dateFormats option sets datetime format for updatedAt", async ({ page }) => { + const setUpdatedAtButton = page.getByTestId("setUpdatedAt"); + await setUpdatedAtButton.waitFor({ state: "visible" }); + await setUpdatedAtButton.click({ force: true }); + await page.waitForTimeout(300); + + // Display should show full ISO string + await expect(updatedAtText(page)).toHaveText("2023-06-20T18:00:00.000Z"); + + // URL should use datetime format from options + await expect(page).toHaveURL(/updatedAt=2023-06-20T18%3A00%3A00\.000Z/); + }); + + test("mixed formats work correctly together", async ({ page }) => { + // Set both dates + await page.getByTestId("setCreatedAt").click(); + await page.getByTestId("setUpdatedAt").click(); + await page.waitForTimeout(300); + + // createdAt serialized as date-only becomes UTC midnight when read back + await expect(createdAtText(page)).toHaveText("2023-06-15T00:00:00.000Z"); + // updatedAt uses full datetime format + await expect(updatedAtText(page)).toHaveText("2023-06-20T18:00:00.000Z"); + + // createdAt should use date-only format + await expect(page).toHaveURL(/createdAt=2023-06-15/); + await expect(page).not.toHaveURL(/createdAt=2023-06-15T/); + + // updatedAt should use datetime format + await expect(page).toHaveURL(/updatedAt=2023-06-20T18%3A00%3A00\.000Z/); + }); + + test("date-only format from URL remains date-only after updates", async ({ page }) => { + // Navigate with date-only format + await page.goto(`${route}?createdAt=2023-01-15`); + await page.waitForTimeout(300); + + // Update the date + await page.getByTestId("setCreatedAt").click(); + await page.waitForTimeout(100); + + // Should still use date-only format in URL + await expect(page).toHaveURL(/createdAt=2023-06-15/); + await expect(page).not.toHaveURL(/createdAt=2023-06-15T/); + }); + }); + + test.describe("zod-codec", () => { + const route = "/test-zod-codec"; + const createdAtText = (page: Page) => page.getByTestId("createdAt"); + const updatedAtText = (page: Page) => page.getByTestId("updatedAt"); + const filterText = (page: Page) => page.getByTestId("filter"); + + test("loads with default values", async ({ page }) => { + await page.goto(route); + await page.waitForTimeout(300); + + // Should load with defaults + await expect(createdAtText(page)).toHaveText("2023-01-01T00:00:00.000Z"); + await expect(updatedAtText(page)).toHaveText("2023-12-31T23:59:59.000Z"); + await expect(filterText(page)).toBeEmpty(); + + // URL should not have parameters with defaults + await expect(page).toHaveURL(route); + }); + + test("codec encodes dates to URL with correct formats", async ({ page }) => { + await page.goto(route); + await page.waitForTimeout(300); + + // Click buttons to set dates + await page.getByTestId("setCreatedAt").click(); + await page.waitForTimeout(100); + await page.getByTestId("setUpdatedAt").click(); + await page.waitForTimeout(100); + + // Dates should be parsed correctly + // Note: createdAt uses date-only codec, so time is stripped and defaults to midnight + await expect(createdAtText(page)).toHaveText("2024-06-15T00:00:00.000Z"); + await expect(updatedAtText(page)).toHaveText("2024-06-20T18:00:00.000Z"); + + // createdAt should use YYYY-MM-DD format (date-only) + await expect(page).toHaveURL(/createdAt=2024-06-15/); + await expect(page).not.toHaveURL(/createdAt=2024-06-15T/); + + // updatedAt should use full ISO datetime format + await expect(page).toHaveURL(/updatedAt=2024-06-20T18%3A00%3A00\.000Z/); + }); + + test("codec decodes URL parameters to Date objects", async ({ page }) => { + // Navigate with date parameters + await page.goto(`${route}?createdAt=2024-10-21&updatedAt=2024-12-31T15:30:00.000Z`); + await page.waitForTimeout(300); + + // Should parse as Date objects and display as ISO strings + await expect(createdAtText(page)).toHaveText("2024-10-21T00:00:00.000Z"); + await expect(updatedAtText(page)).toHaveText("2024-12-31T15:30:00.000Z"); + }); + + test("codec handles invalid date formats gracefully", async ({ page }) => { + // Navigate with invalid date + await page.goto(`${route}?createdAt=invalid-date`); + await page.waitForTimeout(300); + + // Should fall back to default + await expect(createdAtText(page)).toHaveText("2023-01-01T00:00:00.000Z"); + }); + + test("codec preserves date format when updating other params", async ({ page }) => { + await page.goto(`${route}?createdAt=2024-06-15`); + await page.waitForTimeout(300); + + // Update filter + await page.getByTestId("filter-input").fill("test"); + await page.waitForTimeout(100); + + // Date format should remain YYYY-MM-DD + await expect(page).toHaveURL(/createdAt=2024-06-15/); + await expect(page).not.toHaveURL(/createdAt=2024-06-15T/); + await expect(page).toHaveURL(/filter=test/); + }); + + test("reset restores codec default values", async ({ page }) => { + await page.goto( + `${route}?createdAt=2024-06-15&updatedAt=2024-06-20T18:00:00.000Z&filter=test` + ); + await page.waitForTimeout(300); + + // Click reset + await page.getByTestId("reset").click(); + await page.waitForTimeout(100); + + // Should restore defaults + await expect(createdAtText(page)).toHaveText("2023-01-01T00:00:00.000Z"); + await expect(updatedAtText(page)).toHaveText("2023-12-31T23:59:59.000Z"); + await expect(filterText(page)).toBeEmpty(); + await expect(page).toHaveURL(route); + }); + + test("codec works with multiple date updates", async ({ page }) => { + await page.goto(route); + await page.waitForTimeout(300); + + // Set createdAt + await page.getByTestId("setCreatedAt").click(); + await page.waitForTimeout(100); + await expect(page).toHaveURL(/createdAt=2024-06-15/); + + // Set updatedAt + await page.getByTestId("setUpdatedAt").click(); + await page.waitForTimeout(100); + await expect(page).toHaveURL(/createdAt=2024-06-15/); + await expect(page).toHaveURL(/updatedAt=2024-06-20T18%3A00%3A00\.000Z/); + + // Both dates should be correctly formatted + // Note: createdAt uses date-only codec, so time is stripped and becomes UTC midnight + await expect(createdAtText(page)).toHaveText("2024-06-15T00:00:00.000Z"); + await expect(updatedAtText(page)).toHaveText("2024-06-20T18:00:00.000Z"); + }); + }); }); diff --git a/packages/runed/src/lib/utilities/use-search-params/use-search-params.svelte.ts b/packages/runed/src/lib/utilities/use-search-params/use-search-params.svelte.ts index aa37540b..b250812e 100644 --- a/packages/runed/src/lib/utilities/use-search-params/use-search-params.svelte.ts +++ b/packages/runed/src/lib/utilities/use-search-params/use-search-params.svelte.ts @@ -71,7 +71,78 @@ export interface SearchParamsOptions { * @default false */ noScroll?: boolean; + + /** + * Specifies which date fields should use date-only format (YYYY-MM-DD) instead of full ISO8601 datetime. + * + * Map field names to their desired format: + * - 'date': Serializes as YYYY-MM-DD (e.g., "2025-10-21") + * - 'datetime': Serializes as full ISO8601 (e.g., "2025-10-21T18:18:14.196Z") + * + * Example: + * ``` + * { dateFormats: { birthDate: 'date', createdAt: 'datetime' } } + * ``` + * + * @default undefined (all dates use datetime format) + */ + dateFormats?: Record; +} + +/** + * Serialize a value to a URL-compatible string representation + * @param value The value to serialize + * @param key The field name (used to look up date format and codec encoder) + * @param dateFormats Map of field names to date formats + * @param codecEncoders Map of field names to codec encoder functions + * @returns String representation of the value + * @internal + */ +function serializeValue( + value: unknown, + key: string, + dateFormats: Map | Record = {}, + codecEncoders: Map unknown> = new Map() +): string { + // First, check if there's a codec encoder for this field + const encoder = codecEncoders.get(key); + if (encoder) { + // Use the codec's encoder to transform the value first + const encodedValue = encoder(value); + // Then convert the encoded value to a string + if (typeof encodedValue === "string") { + return encodedValue; + } + // If encoder returns non-string, continue with normal serialization + value = encodedValue; + } + + if (value instanceof Date) { + // Check if this field should use date-only format + const format = dateFormats instanceof Map ? dateFormats.get(key) : dateFormats[key]; + if (format === "date") { + // Format as YYYY-MM-DD using UTC date components to avoid timezone issues + // This ensures consistent serialization regardless of local timezone + const iso = value.toISOString(); + return iso.split("T")[0]!; // Extract YYYY-MM-DD portion (always present) + } else { + // Default to full ISO8601 datetime + return value.toISOString(); + } + } else if (Array.isArray(value)) { + return JSON.stringify(value); + } else if (typeof value === "object" && value !== null) { + return JSON.stringify(value); + } else { + return String(value); + } } +type ExtractParamValuesOptions = { + numberFields?: Set; + arrayFields?: Set; + dateFields?: Set; + codecFields?: Set; +}; /** * Extract and pre-process values from URLSearchParams @@ -82,14 +153,21 @@ export interface SearchParamsOptions { * @param searchParams The URLSearchParams object to extract values from * @param numberFields Optional set of field names that should be treated as numbers * @param arrayFields Optional set of field names that should be treated as arrays (comma-separated values will be split) + * @param dateFields Optional set of field names that should be treated as dates + * @param codecFields Optional set of field names that have codecs (skip automatic conversion for these) * @returns An object with processed parameter values * @internal */ export function extractParamValues( searchParams: URLSearchParams, - numberFields: Set = new Set(), - arrayFields: Set = new Set() + options: ExtractParamValuesOptions = {} ): Record { + const { + numberFields = new Set(), + arrayFields = new Set(), + dateFields = new Set(), + codecFields = new Set(), + } = options; const params: Record = {}; for (const [key, value] of searchParams.entries()) { @@ -118,11 +196,21 @@ export function extractParamValues( else if (numberFields.has(key) && value.trim() !== "" && !isNaN(Number(value))) { params[key] = Number(value); } - // Handle comma-separated values as arrays (fallback format) - ONLY for array fields + // Convert to Date if the schema expects a date, the value is a valid ISO8601 string, + // AND the field doesn't have a codec (codecs handle their own conversion) + else if (dateFields.has(key) && !codecFields.has(key) && value.trim() !== "") { + const dateValue = new Date(value); + if (!isNaN(dateValue.getTime())) { + params[key] = dateValue; + } else { + params[key] = value; // Keep as string if not a valid date + } + } + // Handle comma-separated values as arrays (fallback format) else if (arrayFields.has(key) && value.includes(",")) { params[key] = value.split(","); } - // Keep everything else as strings + // Keep everything else as strings (including codec fields) else { params[key] = value; } @@ -145,10 +233,57 @@ interface SchemaInfo { keys: string[]; /** Set of field names that expect number types */ numberFields: Set; + /** Set of field names that expect Date types */ + dateFields: Set; + /** Map of date field names to their format ('date' or 'datetime') */ + dateFormats: Map; /** Set of field names that expect array types */ arrayFields: Set; /** Default values for all fields */ defaultValues: Record; + /** Map of field names to their codec encode functions (for serialization) */ + codecEncoders: Map unknown>; + /** Set of field names that have codecs (used to skip automatic type conversion) */ + codecFields: Set; +} + +/** + * Detect and extract codec encoder from a Zod schema field + * Returns the encoder function if available, otherwise undefined + * @internal + */ +function extractZodCodecEncoder(fieldSchema: unknown): ((value: unknown) => unknown) | undefined { + // Check if this looks like a Zod schema with def property + const zodLike = fieldSchema as { + def?: { + type?: string; + innerType?: { + def?: { + type?: string; + reverseTransform?: (value: unknown) => unknown; + }; + }; + reverseTransform?: (value: unknown) => unknown; + }; + }; + + if (!zodLike.def) return undefined; + + // Case 1: Direct codec (e.g., z.codec(...)) + if (zodLike.def.type === "pipe" && typeof zodLike.def.reverseTransform === "function") { + return zodLike.def.reverseTransform; + } + + // Case 2: Codec wrapped in .default() (e.g., z.codec(...).default(...)) + if ( + zodLike.def.type === "default" && + zodLike.def.innerType?.def?.type === "pipe" && + typeof zodLike.def.innerType.def.reverseTransform === "function" + ) { + return zodLike.def.innerType.def.reverseTransform; + } + + return undefined; } /** @@ -167,24 +302,71 @@ function extractSchemaInfo(schema: Schema): Sch const validationResult = schema["~standard"].validate({}); if (!validationResult || !("value" in validationResult)) { - return { keys: [], numberFields: new Set(), arrayFields: new Set(), defaultValues: {} }; + return { + keys: [], + numberFields: new Set(), + dateFields: new Set(), + dateFormats: new Map(), + defaultValues: {}, + codecEncoders: new Map(), + codecFields: new Set(), + arrayFields: new Set(), + }; } const defaultValues = validationResult.value as Record; const keys = Object.keys(defaultValues); const numberFields = new Set(); + const dateFields = new Set(); + const dateFormats = new Map(); + const codecEncoders = new Map unknown>(); + const codecFields = new Set(); const arrayFields = new Set(); - // Determine which fields are number or array types by checking default value types + // Determine which fields are number, date or array types by checking default value types for (const [key, defaultValue] of Object.entries(defaultValues)) { if (typeof defaultValue === "number") { numberFields.add(key); + } else if (defaultValue instanceof Date) { + dateFields.add(key); } else if (Array.isArray(defaultValue)) { arrayFields.add(key); } } - return { keys, numberFields, arrayFields, defaultValues }; + // Extract date formats from schema metadata if available (from createSearchParamsSchema) + const schemaWithMetadata = schema as Schema & { + __dateFormats?: Record; + }; + if (schemaWithMetadata.__dateFormats) { + for (const [key, format] of Object.entries(schemaWithMetadata.__dateFormats)) { + dateFormats.set(key, format); + } + } + + // Try to extract codec encoders from Zod schemas + // Check if the schema has a shape property (Zod object schema) + const zodObjectSchema = schema as { shape?: Record }; + if (zodObjectSchema.shape) { + for (const [key, fieldSchema] of Object.entries(zodObjectSchema.shape)) { + const encoder = extractZodCodecEncoder(fieldSchema); + if (encoder) { + codecEncoders.set(key, encoder); + codecFields.add(key); + } + } + } + + return { + keys, + numberFields, + dateFields, + dateFormats, + defaultValues, + codecEncoders, + codecFields, + arrayFields, + }; } /** @@ -196,15 +378,22 @@ function extractSchemaInfo(schema: Schema): Sch * @param schemaKeys Array of parameter keys that are defined in the schema * @param numberFields Set of field names that should be treated as numbers * @param arrayFields Set of field names that should be treated as arrays (comma-separated values will be split) + * @param dateFields Set of field names that should be treated as dates + * @param codecFields Set of field names that have codecs (skip automatic conversion for these) * @returns An object with processed parameter values for schema-defined keys only * @internal */ function extractSelectiveParamValues( searchParams: URLSearchParams, schemaKeys: string[], - numberFields: Set = new Set(), - arrayFields: Set = new Set() + options: ExtractParamValuesOptions = {} ): Record { + const { + numberFields = new Set(), + arrayFields = new Set(), + dateFields = new Set(), + codecFields = new Set(), + } = options; const params: Record = {}; // Only access parameters that are defined in the schema @@ -238,11 +427,21 @@ function extractSelectiveParamValues( else if (numberFields.has(key) && value.trim() !== "" && !isNaN(Number(value))) { params[key] = Number(value); } + // Convert to Date if the schema expects a date, the value is a valid ISO8601 string, + // AND the field doesn't have a codec (codecs handle their own conversion) + else if (dateFields.has(key) && !codecFields.has(key) && value.trim() !== "") { + const dateValue = new Date(value); + if (!isNaN(dateValue.getTime())) { + params[key] = dateValue; + } else { + params[key] = value; // Keep as string if not a valid date + } + } // Handle comma-separated values as arrays (fallback format) - ONLY for array fields else if (arrayFields.has(key) && value.includes(",")) { params[key] = value.split(","); } - // Keep everything else as strings + // Keep everything else as strings (including codec fields) else { params[key] = value; } @@ -362,6 +561,30 @@ class SearchParams { */ #numberFields: Set; + /** + * Set of field names that expect Date types based on schema validation + * Used to intelligently convert URL string values to Dates only when appropriate + */ + #dateFields: Set; + + /** + * Map of date field names to their format preference ('date' or 'datetime') + * Determines serialization format for Date values in URLs + */ + #dateFormats: Map; + + /** + * Map of field names to their codec encoder functions + * Used to serialize values using custom codecs (e.g., Zod codecs) + */ + #codecEncoders: Map unknown>; + + /** + * Set of field names that have codecs + * Used to skip automatic type conversion for codec fields + */ + #codecFields: Set; + /** * Set of field names that expect array types based on schema validation * Used to determine when to split comma-separated values into arrays @@ -420,10 +643,22 @@ class SearchParams { {} as Record ); - // Store default values, number fields, and array fields + // Store default values, number fields, and date fields this.#defaultValues = { ...schemaInfo.defaultValues }; this.#numberFields = schemaInfo.numberFields; + this.#numberFields = schemaInfo.numberFields; this.#arrayFields = schemaInfo.arrayFields; + this.#dateFields = schemaInfo.dateFields; + + // Merge date formats from schema info and options + this.#dateFormats = new Map(schemaInfo.dateFormats); + if (options.dateFormats) { + for (const [key, format] of Object.entries(options.dateFormats)) { + this.#dateFormats.set(key, format); + } + } + this.#codecEncoders = schemaInfo.codecEncoders; + this.#codecFields = schemaInfo.codecFields; } /** @@ -452,7 +687,7 @@ class SearchParams { // populate cache with decompressed values for (const [key, value] of Object.entries(decompressedObj)) { - const stringValue = this.#serializeValue(value); + const stringValue = this.#serializeValue(value, key); newCache.set(key, stringValue); } @@ -627,8 +862,26 @@ class SearchParams { // no changes, skip update if (!hasChanges) return; - // Create a new object with the updated values - const newParamsObject = { ...paramsObject, ...filteredValues }; + // Encode values for codec fields before validation + // Codecs expect INPUT types during validation (e.g., strings), + // but users provide OUTPUT types (e.g., Dates) + const valuesForValidation: Record = {}; + for (const [key, value] of Object.entries(filteredValues)) { + if (this.#codecEncoders.has(key)) { + const encoder = this.#codecEncoders.get(key)!; + try { + valuesForValidation[key] = encoder(value); + } catch (e) { + console.error(`Error encoding value for field "${key}"`, e); + valuesForValidation[key] = value; // Use original value if encoding fails + } + } else { + valuesForValidation[key] = value; + } + } + + // Create a new object with the updated values (using encoded values for validation) + const newParamsObject = { ...paramsObject, ...valuesForValidation }; // Validate against schema const result = this.validate(newParamsObject); @@ -705,7 +958,7 @@ class SearchParams { // Always update local cache immediately const newCache = new SvelteURLSearchParams(); for (const [key, value] of Object.entries(validDefaultValues)) { - const stringValue = this.#serializeValue(value); + const stringValue = this.#serializeValue(value, key); newCache.set(key, stringValue); } this.#localCache = newCache; @@ -714,7 +967,7 @@ class SearchParams { if (this.#options.updateURL && BROWSER && !building) { const urlParams = new URLSearchParams(); for (const [key, value] of Object.entries(validDefaultValues)) { - const stringValue = this.#serializeValue(value); + const stringValue = this.#serializeValue(value, key); urlParams.set(key, stringValue); } this.#navigateWithParams(urlParams); @@ -787,7 +1040,7 @@ class SearchParams { if (validatedValue === undefined || validatedValue === null || isDefaultValue) { newSearchParams.delete(key); } else { - const stringValue = this.#serializeValue(validatedValue); + const stringValue = this.#serializeValue(validatedValue, key); newSearchParams.set(key, stringValue); } } @@ -801,8 +1054,19 @@ class SearchParams { */ #handleCompressedUpdate(fullParamsObject: Record): void { try { - // Convert the entire parameters object to JSON - const jsonData = JSON.stringify(fullParamsObject); + // Start with all values, then serialize only fields that need special handling + const serializedObject: Record = { ...fullParamsObject }; + + // Serialize date field and codec fields + for (const key of [...this.#dateFields, ...this.#codecFields]) { + const value = fullParamsObject[key]; + if (value !== undefined && value !== null) { + serializedObject[key] = this.#serializeValue(value, key); + } + } + + // Convert the serialized parameters object to JSON + const jsonData = JSON.stringify(serializedObject); // Compress the JSON string const compressed = lzString.compressToEncodedURIComponent(jsonData); @@ -825,7 +1089,7 @@ class SearchParams { continue; } - const stringValue = this.#serializeValue(value); + const stringValue = this.#serializeValue(value, key); newSearchParams.set(key, stringValue); } @@ -866,17 +1130,11 @@ class SearchParams { /** * Converts a value to a URL-compatible string representation - * Handles arrays, objects, and primitive values + * Handles arrays, objects, dates, and primitive values * @private */ - #serializeValue(value: unknown): string { - if (Array.isArray(value)) { - return JSON.stringify(value); - } else if (typeof value === "object" && value !== null) { - return JSON.stringify(value); - } else { - return String(value); - } + #serializeValue(value: unknown, key?: string): string { + return serializeValue(value, key || "", this.#dateFormats, this.#codecEncoders); } #extractParamValues(searchParams: URLSearchParams): Record { @@ -904,7 +1162,12 @@ class SearchParams { } // If not using compression, use the normal extraction with number and array field detection - return extractParamValues(searchParams, this.#numberFields, this.#arrayFields); + return extractParamValues(searchParams, { + numberFields: this.#numberFields, + arrayFields: this.#arrayFields, + dateFields: this.#dateFields, + codecFields: this.#codecFields, + }); } /** @@ -992,8 +1255,22 @@ class SearchParams { return; } - // Create a new object with the updated value - const newParamsObject = { ...paramsObject, [key]: value }; + // If this field has a codec encoder, we need to encode the value before validation + // This is because codecs expect INPUT types (e.g., strings) during validation, + // but users set OUTPUT types (e.g., Dates) through the API + let valueForValidation = value; + if (this.#codecEncoders.has(key)) { + const encoder = this.#codecEncoders.get(key)!; + try { + valueForValidation = encoder(value); + } catch (e) { + console.error(`Error encoding value for field "${key}"`, e); + // If encoding fails, use the original value + } + } + + // Create a new object with the updated value (using encoded value for validation) + const newParamsObject = { ...paramsObject, [key]: valueForValidation }; // Validate against schema to ensure type correctness const result = this.validate(newParamsObject); @@ -1040,6 +1317,7 @@ export type SchemaTypeConfig = | { type: "string"; default?: string } | { type: "number"; default?: number } | { type: "boolean"; default?: boolean } + | { type: "date"; default?: Date; dateFormat?: "date" | "datetime" } | { type: "array"; default?: ArrayType[]; arrayType?: ArrayType } | { type: "object"; default?: ObjectType; objectType?: ObjectType }; @@ -1066,6 +1344,7 @@ export type SchemaTypeConfig = * page: { type: 'number', default: 1 }, * filter: { type: 'string', default: '' }, * sort: { type: 'string', default: 'newest' }, + * createdAt: { type: 'date', default: new Date() }, * * // Array type with specific element type * tags: { @@ -1086,6 +1365,7 @@ export type SchemaTypeConfig = * URL storage format: * - Arrays are stored as JSON strings: ?tags=["sale","featured"] * - Objects are stored as JSON strings: ?config={"theme":"dark","fontSize":14} + * - Dates are stored as ISO8601 strings: ?createdAt=2023-12-01T10:30:00.000Z * - Primitive values are stored directly: ?page=2&filter=red */ export function createSearchParamsSchema>( @@ -1097,15 +1377,17 @@ export function createSearchParamsSchema - : O - : string; + : T[K] extends { type: "date" } + ? Date + : T[K] extends { type: "array"; arrayType?: infer A } + ? unknown extends A + ? unknown[] + : A[] + : T[K] extends { type: "object"; objectType?: infer O } + ? unknown extends O + ? Record + : O + : string; } > { type Output = { @@ -1113,15 +1395,17 @@ export function createSearchParamsSchema - : O - : string; + : T[K] extends { type: "date" } + ? Date + : T[K] extends { type: "array"; arrayType?: infer A } + ? unknown extends A + ? unknown[] + : A[] + : T[K] extends { type: "object"; objectType?: infer O } + ? unknown extends O + ? Record + : O + : string; }; return { @@ -1172,6 +1456,29 @@ export function createSearchParamsSchema)[key] = dateValue; + } else { + issues.push({ + message: `Invalid date for "${key}"`, + path: [key], + }); + } + break; + } case "array": { if (Array.isArray(inputValue)) { (output as Record)[key] = inputValue; @@ -1223,7 +1530,36 @@ export function createSearchParamsSchema { + if (config.type === "date" && config.dateFormat) { + acc[key] = config.dateFormat; + } + return acc; + }, + {} as Record + ), + } as StandardSchemaV1< + unknown, + { + [K in keyof T]: T[K] extends { type: "number" } + ? number + : T[K] extends { type: "boolean" } + ? boolean + : T[K] extends { type: "date" } + ? Date + : T[K] extends { type: "array"; arrayType?: infer A } + ? unknown extends A + ? unknown[] + : A[] + : T[K] extends { type: "object"; objectType?: infer O } + ? unknown extends O + ? Record + : O + : string; + } + > & { __dateFormats?: Record }; } /** @@ -1269,11 +1605,16 @@ export function createSearchParamsSchema( url: URL, schema: Schema, - options: { compressedParamName?: string } = {} + options: { compressedParamName?: string; dateFormats?: Record } = {} ): { searchParams: URLSearchParams; data: StandardSchemaV1.InferOutput } { const compressedParamName = options.compressedParamName || "_data"; + const dateFormats = options.dateFormats || {}; let validatedValue: Record = {}; + // Extract codec encoders from the schema + const schemaInfo = extractSchemaInfo(schema); + const codecEncoders = schemaInfo.codecEncoders; + // Check if we're dealing with compressed data and handle appropriately if (url.searchParams.has(compressedParamName)) { try { @@ -1324,12 +1665,12 @@ export function validateSearchParams( } else { // Normal (uncompressed) extraction - use selective extraction for fine-grained reactivity const schemaInfo = extractSchemaInfo(schema); - const paramsObject = extractSelectiveParamValues( - url.searchParams, - schemaInfo.keys, - schemaInfo.numberFields, - schemaInfo.arrayFields - ); + const paramsObject = extractSelectiveParamValues(url.searchParams, schemaInfo.keys, { + numberFields: schemaInfo.numberFields, + dateFields: schemaInfo.dateFields, + codecFields: schemaInfo.codecFields, + arrayFields: schemaInfo.arrayFields, + }); // Validate the parameters against the schema let result = schema["~standard"].validate(paramsObject); @@ -1358,21 +1699,10 @@ export function validateSearchParams( // Create a new URLSearchParams object with the validated values const newSearchParams = new URLSearchParams(); - // Helper function to serialize values - const serializeValue = (value: unknown): string => { - if (Array.isArray(value)) { - return JSON.stringify(value); - } else if (typeof value === "object" && value !== null) { - return JSON.stringify(value); - } else { - return String(value); - } - }; - // Add each validated parameter to the URLSearchParams for (const [key, value] of Object.entries(validatedValue)) { if (value === undefined || value === null) continue; - const stringValue = serializeValue(value); + const stringValue = serializeValue(value, key, dateFormats, codecEncoders); newSearchParams.set(key, stringValue); } @@ -1507,16 +1837,17 @@ export function useSearchParams( // Only run initialization logic after hydration is complete $effect(() => { - if (!isMounted.current || !browser || building) return; + if (!isMounted.current || building) return; // Remove incorrect params on initialization (only after hydration, only once) if (!hasInitialized && options.updateURL !== false) { const schemaInfo = extractSchemaInfo(schema); - const currentParams = extractParamValues( - page.url.searchParams, - schemaInfo.numberFields, - schemaInfo.arrayFields - ); + const currentParams = extractParamValues(page.url.searchParams, { + numberFields: schemaInfo.numberFields, + dateFields: schemaInfo.dateFields, + codecFields: schemaInfo.codecFields, + arrayFields: schemaInfo.arrayFields, + }); const validationResult = schema["~standard"].validate(currentParams); if ( validationResult && @@ -1546,6 +1877,13 @@ export function useSearchParams( const schemaInfo = extractSchemaInfo(schema); if (schemaInfo.keys.length > 0) { + // Merge date formats from schema and options + const dateFormats = new Map(schemaInfo.dateFormats); + if (options.dateFormats) { + for (const [key, format] of Object.entries(options.dateFormats)) { + dateFormats.set(key, format); + } + } // 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 @@ -1553,12 +1891,13 @@ export function useSearchParams( schemaInfo.defaultValues as Partial> ); } else { - // For non-compressed mode, use the original approach - const currentParams = extractParamValues( - page.url.searchParams, - schemaInfo.numberFields, - schemaInfo.arrayFields - ); + // For non-compressed mode, manually build the URL + const currentParams = extractParamValues(page.url.searchParams, { + numberFields: schemaInfo.numberFields, + dateFields: schemaInfo.dateFields, + codecFields: schemaInfo.codecFields, + arrayFields: schemaInfo.arrayFields, + }); const newSearchParams = new URLSearchParams(page.url.searchParams.toString()); let needsUpdate = false; @@ -1573,15 +1912,8 @@ export function useSearchParams( continue; } - let stringValue: string; - if (Array.isArray(defaultValue)) { - stringValue = JSON.stringify(defaultValue); - } else if (typeof defaultValue === "object" && defaultValue !== null) { - stringValue = JSON.stringify(defaultValue); - } else { - stringValue = String(defaultValue); - } - + // Use shared serialization logic that respects date formats + const stringValue = serializeValue(defaultValue, key, dateFormats); newSearchParams.set(key, stringValue); } @@ -1594,7 +1926,6 @@ export function useSearchParams( } } } - hasInitialized = true; }); diff --git a/packages/runed/src/lib/utilities/use-search-params/validate-search-params.test.ts b/packages/runed/src/lib/utilities/use-search-params/validate-search-params.test.ts index 9fae3b50..743920cb 100644 --- a/packages/runed/src/lib/utilities/use-search-params/validate-search-params.test.ts +++ b/packages/runed/src/lib/utilities/use-search-params/validate-search-params.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeAll } from "vitest"; import { createSearchParamsSchema, validateSearchParams } from "./use-search-params.svelte.js"; import * as lzString from "lz-string"; +// Set timezone to EDT (America/New_York) for consistent test results +beforeAll(() => { + if (!process.env.TZ) { + process.env.TZ = "America/New_York"; + } +}); + // Reuse the schema from the test page const testSchema = createSearchParamsSchema({ page: { type: "number", default: 1 }, @@ -9,13 +16,16 @@ const testSchema = createSearchParamsSchema({ active: { type: "boolean", default: false }, tags: { type: "array", default: [], arrayType: "" }, config: { type: "object", default: {}, objectType: { theme: "" } }, + startDate: { type: "date", default: new Date("2023-01-01T00:00:00Z") }, + endDate: { type: "date", default: new Date("2023-12-31T23:59:59Z") }, }); // Helper to create URL objects const createURL = (search: string) => new URL(`http://localhost:5173${search}`); // Define expected default string once -const expectedDefaultsString = "page=1&filter=&active=false&tags=%5B%5D&config=%7B%7D"; +const expectedDefaultsString = + "page=1&filter=&active=false&tags=%5B%5D&config=%7B%7D&startDate=2023-01-01T00%3A00%3A00.000Z&endDate=2023-12-31T23%3A59%3A59.000Z"; describe("validateSearchParams", () => { it("parses standard URL parameters correctly, including defaults for missing", () => { @@ -464,73 +474,378 @@ describe("validateSearchParams", () => { }); }); - describe("comma handling (issue #367)", () => { - it("preserves commas in string parameters without splitting into arrays", () => { - const schema = createSearchParamsSchema({ - name: { type: "string", default: "" }, - description: { type: "string", default: "" }, - tags: { type: "array", default: [], arrayType: "" }, + describe("Date parameter support", () => { + it("parses ISO8601 date strings from URL correctly", () => { + const url = createURL( + "?page=3&filter=test&startDate=2023-06-15T10:30:00.000Z&endDate=2023-06-20T18:00:00.000Z" + ); + const { searchParams, data } = validateSearchParams(url, testSchema); + + // Check URLSearchParams + expect(searchParams.get("page")).toBe("3"); + expect(searchParams.get("filter")).toBe("test"); + expect(searchParams.get("startDate")).toBe("2023-06-15T10:30:00.000Z"); + expect(searchParams.get("endDate")).toBe("2023-06-20T18:00:00.000Z"); + + // Check typed data object + expect(data.page).toBe(3); // number + expect(data.filter).toBe("test"); // string + expect(data.startDate).toBeInstanceOf(Date); + expect(data.endDate).toBeInstanceOf(Date); + expect(data.startDate.toISOString()).toBe("2023-06-15T10:30:00.000Z"); + expect(data.endDate.toISOString()).toBe("2023-06-20T18:00:00.000Z"); + }); + + it("returns default Date values when dates are missing from URL", () => { + const url = createURL("?page=5&filter=test"); // No date parameters + const { searchParams, data } = validateSearchParams(url, testSchema); + + // Check URLSearchParams - should include defaults + expect(searchParams.get("page")).toBe("5"); + expect(searchParams.get("filter")).toBe("test"); + expect(searchParams.get("startDate")).toBe("2023-01-01T00:00:00.000Z"); + expect(searchParams.get("endDate")).toBe("2023-12-31T23:59:59.000Z"); + + // Check typed data object + expect(data.page).toBe(5); + expect(data.filter).toBe("test"); + expect(data.startDate).toBeInstanceOf(Date); + expect(data.endDate).toBeInstanceOf(Date); + expect(data.startDate.toISOString()).toBe("2023-01-01T00:00:00.000Z"); + expect(data.endDate.toISOString()).toBe("2023-12-31T23:59:59.000Z"); + }); + + it("handles invalid date strings by falling back to defaults", () => { + const url = createURL("?endDate=invalid-date"); + const { searchParams, data } = validateSearchParams(url, testSchema); + + // Check URLSearchParams - invalid date should use default + expect(searchParams.get("page")).toBe("1"); // default + expect(searchParams.get("filter")).toBe(""); // default + expect(searchParams.get("startDate")).toBe("2023-01-01T00:00:00.000Z"); // default (invalid input) + expect(searchParams.get("endDate")).toBe("2023-12-31T23:59:59.000Z"); // default (invalid input) + + // Check typed data object + expect(data.page).toBe(1); + expect(data.filter).toBe(""); + expect(data.startDate).toBeInstanceOf(Date); + expect(data.endDate).toBeInstanceOf(Date); + expect(data.startDate.toISOString()).toBe("2023-01-01T00:00:00.000Z"); + expect(data.endDate.toISOString()).toBe("2023-12-31T23:59:59.000Z"); + }); + + it("handles compressed parameters with dates", () => { + const dataToCompress = { + page: 5, + filter: "compressed", + startDate: new Date("2023-06-15T10:30:00.000Z"), + endDate: new Date("2023-06-20T18:00:00.000Z"), + }; + const compressed = lzString.compressToEncodedURIComponent( + JSON.stringify({ + ...dataToCompress, + startDate: dataToCompress.startDate.toISOString(), // Dates are serialized as ISO strings + endDate: dataToCompress.endDate.toISOString(), + }) + ); + const url = createURL(`?_data=${compressed}`); + const { searchParams, data } = validateSearchParams(url, testSchema); + + // Check URLSearchParams + expect(searchParams.get("page")).toBe("5"); + expect(searchParams.get("filter")).toBe("compressed"); + expect(searchParams.get("startDate")).toBe("2023-06-15T10:30:00.000Z"); + expect(searchParams.get("endDate")).toBe("2023-06-20T18:00:00.000Z"); + + // Check typed data object + expect(data.page).toBe(5); + expect(data.filter).toBe("compressed"); + expect(data.startDate).toBeInstanceOf(Date); + expect(data.endDate).toBeInstanceOf(Date); + expect(data.startDate.toISOString()).toBe("2023-06-15T10:30:00.000Z"); + expect(data.endDate.toISOString()).toBe("2023-06-20T18:00:00.000Z"); + }); + + it("respects dateFormats option for date-only serialization", () => { + // Use date-only format in URL (parsed as UTC midnight) + const url = createURL("?startDate=2023-06-15&endDate=2023-06-20T18:00:00.000Z"); + const { searchParams, data } = validateSearchParams(url, testSchema, { + dateFormats: { + startDate: "date", // Date-only format + endDate: "datetime", // Full datetime format + }, }); - // string params with commas should NOT be split into arrays - const url = createURL("?name=Smith, John&description=Hello, world!"); - const { data } = validateSearchParams(url, schema); + // startDate should be serialized as YYYY-MM-DD (date-only format) + expect(searchParams.get("startDate")).toBe("2023-06-15"); + // endDate should be serialized as full ISO8601 (datetime) + expect(searchParams.get("endDate")).toBe("2023-06-20T18:00:00.000Z"); - // strings with commas should remain as single string values - expect(data.name).toBe("Smith, John"); - expect(typeof data.name).toBe("string"); - expect(data.description).toBe("Hello, world!"); - expect(typeof data.description).toBe("string"); + // Check that startDate was parsed correctly + expect(data.startDate).toBeInstanceOf(Date); + // Date-only strings like "2023-06-15" are parsed as UTC midnight + expect(data.startDate.toISOString()).toBe("2023-06-15T00:00:00.000Z"); }); - it("splits comma-separated values only for array type parameters", () => { - const schema = createSearchParamsSchema({ - name: { type: "string", default: "" }, - tags: { type: "array", default: [], arrayType: "" }, + it("uses datetime format by default when dateFormats not specified", () => { + const url = createURL("?startDate=2023-06-15T10:30:00.000Z"); + const { searchParams, data } = validateSearchParams(url, testSchema); + + // Should use full ISO8601 by default (datetime format) + expect(searchParams.get("startDate")).toBe("2023-06-15T10:30:00.000Z"); + + // Check that the date was parsed correctly + expect(data.startDate).toBeInstanceOf(Date); + expect(data.startDate.toISOString()).toBe("2023-06-15T10:30:00.000Z"); + }); + }); + + describe("Zod codec support", () => { + it("works with z.codec for date with YYYY-MM-DD format", async () => { + // Dynamically import Zod to make it optional + const { z } = await import("zod"); + + // Create a codec that converts between ISO date string and Date object + const stringToDate = z.codec( + z.iso.date(), // input schema: ISO date string + z.date(), // output schema: Date object + { + decode: (isoString) => new Date(isoString), // ISO string → Date + encode: (date) => date.toISOString().split("T")[0]!, // Date → YYYY-MM-DD + } + ); + + const zodSchema = z.object({ + createdAt: stringToDate.default(() => new Date("2023-01-01T00:00:00Z")), }); - // comma-separated values in array params SHOULD be split - const url = createURL("?name=Smith, John&tags=tag1,tag2,tag3"); - const { data } = validateSearchParams(url, schema); + // Simulate URL with YYYY-MM-DD format + const url = createURL("?createdAt=2024-06-15"); + + const { searchParams, data } = validateSearchParams(url, zodSchema); - // string param should remain intact - expect(data.name).toBe("Smith, John"); - expect(typeof data.name).toBe("string"); + // Data should be parsed as Date object + expect(data.createdAt).toBeInstanceOf(Date); + expect(data.createdAt.toISOString()).toBe("2024-06-15T00:00:00.000Z"); - // array param should be split by comma - expect(data.tags).toEqual(["tag1", "tag2", "tag3"]); - expect(Array.isArray(data.tags)).toBe(true); + // URL should be in YYYY-MM-DD format (not full ISO string) + expect(searchParams.get("createdAt")).toBe("2024-06-15"); }); - it("handles edge case of string with multiple commas", () => { - const schema = createSearchParamsSchema({ - address: { type: "string", default: "" }, + it("works with z.codec for date with full ISO datetime format", async () => { + const { z } = await import("zod"); + + // Codec with full ISO datetime encoding - use z.iso.datetime() for full timestamps + const stringToDate = z.codec( + z.iso.datetime(), // Accepts full ISO datetime strings + z.date(), + { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString(), // Full ISO string + } + ); + + const zodSchema = z.object({ + updatedAt: stringToDate.default(() => new Date("2023-12-31T23:59:59Z")), }); - const url = createURL("?address=123 Main St, Apt 4B, City, State, 12345"); - const { data } = validateSearchParams(url, schema); + const url = createURL("?updatedAt=2024-06-20T18:30:00.000Z"); - // should keep full string with all commas intact - expect(data.address).toBe("123 Main St, Apt 4B, City, State, 12345"); - expect(typeof data.address).toBe("string"); + const { searchParams, data } = validateSearchParams(url, zodSchema); + + expect(data.updatedAt).toBeInstanceOf(Date); + expect(data.updatedAt.toISOString()).toBe("2024-06-20T18:30:00.000Z"); + + // Should preserve full ISO format + expect(searchParams.get("updatedAt")).toBe("2024-06-20T18:30:00.000Z"); }); - it("handles empty array using JSON format vs empty string", () => { - const schema = createSearchParamsSchema({ - tags: { type: "array", default: ["default"], arrayType: "" }, - emptyString: { type: "string", default: "default" }, + it("works with z.codec for Unix timestamp format", async () => { + const { z } = await import("zod"); + + // Codec that uses Unix timestamps (seconds since epoch) + // Note: URL params are strings, so input schema should parse string to number + const unixTimestampCodec = z.codec( + z.string().transform((val) => parseInt(val, 10)), // input: string -> number + z.date(), // output: Date object + { + decode: (timestamp) => new Date(timestamp * 1000), + encode: (date) => Math.floor(date.getTime() / 1000), + } + ); + + const zodSchema = z.object({ + timestamp: unixTimestampCodec.default(() => new Date("2023-01-01T00:00:00Z")), }); - const url = createURL("?tags=[]&emptyString="); - const { data } = validateSearchParams(url, schema); + // Unix timestamp for 2024-06-15T12:00:00Z is 1718452800 + const url = createURL("?timestamp=1718452800"); + + const { searchParams, data } = validateSearchParams(url, zodSchema); + + expect(data.timestamp).toBeInstanceOf(Date); + expect(data.timestamp.toISOString()).toBe("2024-06-15T12:00:00.000Z"); + + // Should serialize back to Unix timestamp + expect(searchParams.get("timestamp")).toBe("1718452800"); + }); - // empty array JSON should parse as empty array - expect(data.tags).toEqual([]); - expect(Array.isArray(data.tags)).toBe(true); + it("supports multiple codecs with different formats in same schema", async () => { + const { z } = await import("zod"); + + // Date-only codec (YYYY-MM-DD format) + const dateOnlyCodec = z.codec( + z.iso.date(), // Only accepts YYYY-MM-DD + z.date(), + { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString().split("T")[0]!, // Date → YYYY-MM-DD + } + ); + + // Full datetime codec (ISO 8601 with time) + const datetimeCodec = z.codec( + z.iso.datetime(), // Accepts full ISO datetime + z.date(), + { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString(), + } + ); + + const zodSchema = z.object({ + birthDate: dateOnlyCodec.default(() => new Date("2000-01-01T00:00:00Z")), + lastLogin: datetimeCodec.default(() => new Date("2023-12-31T23:59:59Z")), + }); - // empty string should remain empty string - expect(data.emptyString).toBe(""); - expect(typeof data.emptyString).toBe("string"); + const url = createURL("?birthDate=1995-05-15&lastLogin=2024-06-20T14:30:00.000Z"); + + const { searchParams, data } = validateSearchParams(url, zodSchema); + + // Both should be Date objects + expect(data.birthDate).toBeInstanceOf(Date); + expect(data.lastLogin).toBeInstanceOf(Date); + + // But serialized differently + expect(searchParams.get("birthDate")).toBe("1995-05-15"); + expect(searchParams.get("lastLogin")).toBe("2024-06-20T14:30:00.000Z"); + }); + + it("codec decode is called automatically during validation", async () => { + const { z } = await import("zod"); + + const stringToDate = z.codec(z.iso.date(), z.date(), { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString().split("T")[0]!, // Date → YYYY-MM-DD + }); + + const zodSchema = z.object({ + eventDate: stringToDate.default(() => new Date("2023-01-01T00:00:00Z")), + }); + + // URL provides string, codec.decode should convert to Date + const url = createURL("?eventDate=2024-10-21"); + + const { data } = validateSearchParams(url, zodSchema); + + // Should be automatically decoded to Date by the codec + expect(data.eventDate).toBeInstanceOf(Date); + expect(data.eventDate.toISOString()).toBe("2024-10-21T00:00:00.000Z"); + }); + + it("falls back to default value when codec receives invalid input", async () => { + const { z } = await import("zod"); + + const stringToDate = z.codec(z.iso.date(), z.date(), { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString().split("T")[0]!, // Date → YYYY-MM-DD + }); + + const defaultDate = new Date("2023-01-01T00:00:00Z"); + const zodSchema = z.object({ + validDate: stringToDate.default(() => defaultDate), + }); + + // Invalid date string + const url = createURL("?validDate=not-a-date"); + + const { searchParams, data } = validateSearchParams(url, zodSchema); + + // Should fall back to default + expect(data.validDate).toBeInstanceOf(Date); + expect(data.validDate.toISOString()).toBe(defaultDate.toISOString()); + + // URL should have default serialized + expect(searchParams.get("validDate")).toBe("2023-01-01"); + describe("comma handling (issue #367)", () => { + it("preserves commas in string parameters without splitting into arrays", () => { + const schema = createSearchParamsSchema({ + name: { type: "string", default: "" }, + description: { type: "string", default: "" }, + tags: { type: "array", default: [], arrayType: "" }, + }); + + // string params with commas should NOT be split into arrays + const url = createURL("?name=Smith, John&description=Hello, world!"); + const { data } = validateSearchParams(url, schema); + + // strings with commas should remain as single string values + expect(data.name).toBe("Smith, John"); + expect(typeof data.name).toBe("string"); + expect(data.description).toBe("Hello, world!"); + expect(typeof data.description).toBe("string"); + }); + + it("splits comma-separated values only for array type parameters", () => { + const schema = createSearchParamsSchema({ + name: { type: "string", default: "" }, + tags: { type: "array", default: [], arrayType: "" }, + }); + + // comma-separated values in array params SHOULD be split + const url = createURL("?name=Smith, John&tags=tag1,tag2,tag3"); + const { data } = validateSearchParams(url, schema); + + // string param should remain intact + expect(data.name).toBe("Smith, John"); + expect(typeof data.name).toBe("string"); + + // array param should be split by comma + expect(data.tags).toEqual(["tag1", "tag2", "tag3"]); + expect(Array.isArray(data.tags)).toBe(true); + }); + + it("handles edge case of string with multiple commas", () => { + const schema = createSearchParamsSchema({ + address: { type: "string", default: "" }, + }); + + const url = createURL("?address=123 Main St, Apt 4B, City, State, 12345"); + const { data } = validateSearchParams(url, schema); + + // should keep full string with all commas intact + expect(data.address).toBe("123 Main St, Apt 4B, City, State, 12345"); + expect(typeof data.address).toBe("string"); + }); + + it("handles empty array using JSON format vs empty string", () => { + const schema = createSearchParamsSchema({ + tags: { type: "array", default: ["default"], arrayType: "" }, + emptyString: { type: "string", default: "default" }, + }); + + const url = createURL("?tags=[]&emptyString="); + const { data } = validateSearchParams(url, schema); + + // empty array JSON should parse as empty array + expect(data.tags).toEqual([]); + expect(Array.isArray(data.tags)).toBe(true); + + // empty string should remain empty string + expect(data.emptyString).toBe(""); + expect(typeof data.emptyString).toBe("string"); + }); + }); }); }); }); diff --git a/packages/runed/src/routes/(test)/test-search/[mode]/+page.svelte b/packages/runed/src/routes/(test)/test-search/[mode]/+page.svelte index 46b4c197..48ee1adf 100644 --- a/packages/runed/src/routes/(test)/test-search/[mode]/+page.svelte +++ b/packages/runed/src/routes/(test)/test-search/[mode]/+page.svelte @@ -10,6 +10,8 @@ const schema = createSearchParamsSchema({ page: { type: "number", default: 1 }, filter: { type: "string", default: "" }, + createdAt: { type: "date", default: new Date("2023-01-01T00:00:00Z"), dateFormat: "date" }, + updatedAt: { type: "date", default: new Date("2023-12-31T23:59:59Z") }, }); const options: SearchParamsOptions = { @@ -20,6 +22,9 @@ ...(mode === "compress" && { compress: true }), ...(mode === "memory" && { updateURL: false }), ...(mode === "no-scroll" && { noScroll: true }), + ...(mode === "date-format-options" && { + dateFormats: { createdAt: "date", updatedAt: "datetime" }, + }), }; const paramsObj = useSearchParams(schema, options); @@ -33,11 +38,29 @@ function setBoth() { paramsObj.update({ page: 5, filter: "bar" }); } + function setCreatedAt() { + paramsObj.createdAt = new Date("2023-06-15T10:30:00Z"); + } + function setUpdatedAt() { + paramsObj.updatedAt = new Date("2023-06-20T18:00:00Z"); + } + + // Create a derived value to avoid potential infinite loops + let createdAtString = $derived( + paramsObj.createdAt instanceof Date ? paramsObj.createdAt.toISOString() : "Invalid Date" + ); + let updatedAtString = $derived( + paramsObj.updatedAt instanceof Date ? paramsObj.updatedAt.toISOString() : "Invalid Date" + ); + + {paramsObj.page} {paramsObj.filter} +{createdAtString} +{updatedAtString} diff --git a/packages/runed/src/routes/(test)/test-zod-codec/+page.svelte b/packages/runed/src/routes/(test)/test-zod-codec/+page.svelte new file mode 100644 index 00000000..9e51b01c --- /dev/null +++ b/packages/runed/src/routes/(test)/test-zod-codec/+page.svelte @@ -0,0 +1,93 @@ + + +
+

Zod Codec Test Page

+ +
+ + + + +
+ +
+
+ Filter: + {paramsObj.filter} +
+
+ CreatedAt (date-only format): + {createdAtString} +
+
+ UpdatedAt (datetime format): + {updatedAtString} +
+
+
+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 886b0c4d..c392eeac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.17)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@24.1.3)(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.17)(typescript@5.9.2))(terser@5.36.0)(tsx@4.20.5)(yaml@2.6.1) + zod: + specifier: ^4.1.0 + version: 4.1.0 sites/docs: devDependencies: @@ -3832,6 +3835,9 @@ packages: zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + zod@4.1.0: + resolution: {integrity: sha512-UWxluYj2IDX9MHRXTMbB/2eeWrAMmmMSESM+MfT9MQw8U1qo9q5ASW08anoJh6AJ7pkt099fLdNFmfI+4aChHg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7795,4 +7801,6 @@ snapshots: zod@3.22.3: {} + zod@4.1.0: {} + zwitch@2.0.4: {} diff --git a/sites/docs/src/content/utilities/use-search-params.md b/sites/docs/src/content/utilities/use-search-params.md index 569fdc40..d9cbfe63 100644 --- a/sites/docs/src/content/utilities/use-search-params.md +++ b/sites/docs/src/content/utilities/use-search-params.md @@ -249,8 +249,107 @@ URL storage format: - Arrays are stored as JSON strings: `?tags=["sale","featured"]` - Objects are stored as JSON strings: `?config={"theme":"dark","fontSize":14}` +- Dates are stored as ISO8601 strings: `?createdAt=2023-12-01T10:30:00.000Z` (or `YYYY-MM-DD` with + date-only format) - Primitive values are stored directly: `?page=2&filter=red` +#### Date Format Support + +You can control how Date parameters are serialized in URLs using two approaches: + +**Option 1: Using `dateFormat` property in schema** + +```ts +const schema = createSearchParamsSchema({ + // Date-only format (YYYY-MM-DD) - great for birth dates, event dates + birthDate: { + type: "date", + default: new Date("1990-01-15"), + dateFormat: "date" + }, + + // Full datetime format (ISO8601) - great for timestamps + createdAt: { + type: "date", + default: new Date(), + dateFormat: "datetime" + }, + + // No format specified - defaults to 'datetime' + updatedAt: { + type: "date", + default: new Date() + } +}); + +const params = useSearchParams(schema); +// URL: ?birthDate=1990-01-15&createdAt=2023-01-01T10:30:00.000Z&updatedAt=2023-12-25T18:30:00.000Z +``` + +**Option 2: Using `dateFormats` option (works with any validator)** + +```ts +// Works with Zod, Valibot, Arktype, or createSearchParamsSchema +const params = useSearchParams(zodSchema, { + dateFormats: { + birthDate: "date", // YYYY-MM-DD + createdAt: "datetime" // ISO8601 + } +}); +``` + +**Date Format Details:** + +- **`'date'` format**: Serializes as `YYYY-MM-DD` (e.g., `2025-10-21`) + - More readable in URLs + - Perfect for calendar dates, birth dates, event dates + - Parsed as Date object with time set to midnight UTC +- **`'datetime'` format** (default): Serializes as full ISO8601 (e.g., `2025-10-21T18:18:14.196Z`) + - Preserves exact time information + - Perfect for timestamps, created/updated times + - Full precision date and time + +**Practical Example:** + +```svelte + + + + + + + +``` + ### `validateSearchParams` A utility function to extract, validate and convert URL search parameters to @@ -266,13 +365,18 @@ Handles both standard URL parameters and compressed parameters (when compression - `url`: The URL object from SvelteKit load function - `schema`: A validation schema (createSearchParamsSchema, Zod, Valibot, etc.) -- `options`: Optional configuration (like custom `compressedParamName`) +- `options`: Optional configuration (like custom `compressedParamName` and `dateFormats`) **Returns:** - An object with `searchParams` and `data` properties, `searchParams` being the validated `URLSearchParams` and `data` being the validated object +**Available options:** + +- `compressedParamName` (string): Custom name for compressed parameter (default: `_data`) +- `dateFormats` (object): Map of field names to date formats (`'date'` or `'datetime'`) + Example with SvelteKit page or layout load function: ```ts @@ -283,7 +387,11 @@ export const load = ({ url, fetch }) => { // Get validated search params as URLSearchParams object // If you use a custom compressedParamName in useSearchParams, provide it here too: const { searchParams } = validateSearchParams(url, productSchema, { - compressedParamName: "_compressed" + compressedParamName: "_compressed", + dateFormats: { + birthDate: "date", // Serialize as YYYY-MM-DD + createdAt: "datetime" // Serialize as ISO8601 + } }); // Use URLSearchParams directly with fetch @@ -294,6 +402,188 @@ export const load = ({ url, fetch }) => { }; ``` +### Advanced: Custom Serialization with Zod Codecs + +For advanced use cases where you need full control over how values are converted between URL strings +and JavaScript types, you can use [Zod codecs](https://zod.dev/?id=codec) (Zod v4.1.0+). Codecs +allow you to define custom bidirectional transformations that work seamlessly with URL parameters. + +#### Why Use Codecs? + +While the built-in `dateFormats` option works well for common cases, codecs give you complete +control over serialization. Use codecs when you need to: + +- **Custom date formats**: Store dates as Unix timestamps, custom date strings, or other formats +- **Complex type conversions**: Convert between incompatible types (e.g., number IDs ↔ full + objects) +- **Data transformation**: Apply transformations during serialization (e.g., normalize, encrypt) +- **Legacy API compatibility**: Match existing URL parameter formats from other systems +- **Optimization**: Use more compact representations (e.g., `1234567890` instead of + `2009-02-13T23:31:30.000Z`) + +#### How Codecs Work + +A Zod codec defines two transformations: + +- **`decode`**: Converts URL string → JavaScript type (when reading from URL) +- **`encode`**: Converts JavaScript type → URL string (when writing to URL) + +```ts +import { z } from "zod"; + +// Example 1: Unix timestamp codec (stores Date as number) +const unixTimestampCodec = z.codec( + z.coerce.number(), // Input: number from URL string + z.date(), // Output: Date object in your app + { + decode: (timestamp) => new Date(timestamp * 1000), // number → Date + encode: (date) => Math.floor(date.getTime() / 1000) // Date → number + } +); + +// Example 2: Date-only codec (stores Date as YYYY-MM-DD) +const dateOnlyCodec = z.codec( + z.string(), // Input: string from URL + z.date(), // Output: Date object in your app + { + decode: (str) => new Date(str + "T00:00:00.000Z"), // "2025-01-15" → Date + encode: (date) => date.toISOString().split("T")[0] // Date → "2025-01-15" + } +); + +// Example 3: Product ID codec (stores number as base36 string for shorter URLs) +const compactIdCodec = z.codec( + z.string(), // Input: base36 string from URL + z.number(), // Output: number in your app + { + decode: (str) => parseInt(str, 36), // "abc123" → 225249695 + encode: (num) => num.toString(36) // 225249695 → "abc123" + } +); +``` + +#### Using Codecs in Your Schema + +```ts +import { z } from "zod"; + +const searchSchema = z.object({ + // Regular fields work as before + query: z.string().default(""), + page: z.coerce.number().default(1), + + // Unix timestamp - more compact than ISO datetime + createdAfter: unixTimestampCodec.default(new Date("2024-01-01")), + + // Date-only format - cleaner for calendar dates + birthDate: dateOnlyCodec.default(new Date("1990-01-15")), + + // Compact product IDs + productId: compactIdCodec.optional() +}); + +const params = useSearchParams(searchSchema); +``` + +#### Real-World Example: Event Search + +```svelte + + + + + + + + +``` + +#### Codec Benefits Summary + +| Feature | Built-in dateFormats | Zod Codecs | +| ------------------------- | ------------------------ | ------------------------------------------------- | +| **Setup complexity** | Simple | More configuration needed | +| **Date formats** | `date` and `datetime` | Any custom format (Unix, relative, custom string) | +| **URL size** | Standard | Can be optimized (e.g., Unix timestamps) | +| **Type conversions** | Date only | Any type (numbers, objects, arrays, etc.) | +| **Validation** | Basic | Full Zod validation + transformation | +| **Reusability** | Per-field config | Create reusable codec definitions | +| **Legacy compatibility** | Limited | Full control over format | +| **Works with validators** | All (Zod, Valibot, etc.) | Zod only (v4.1.0+) | +| **Server-side usage** | Use `dateFormats` option | Automatic with `validateSearchParams` | + +**When to use `dateFormats`**: Most applications with standard date handling needs + +**When to use codecs**: When you need custom formats, compact representations, or complex type +conversions + +#### Server-Side Usage with Codecs + +Codecs work automatically with `validateSearchParams`: + +```ts +// +page.server.ts +import { validateSearchParams } from "runed/kit"; +import { eventSearchSchema } from "./schemas"; // Schema with codecs + +export const load = ({ url }) => { + // Codecs are automatically applied during validation + const { searchParams, data } = validateSearchParams(url, eventSearchSchema); + + // data.eventDate is a Date object (decoded from URL string) + // searchParams contains properly encoded values for API calls + return { + events: await fetchEvents(searchParams) + }; +}; +``` + ## Reactivity Limitations ### Understanding Reactivity Scope @@ -399,6 +689,20 @@ interface SearchParamsOptions { * @default false */ noScroll?: boolean; + + /** + * Specifies which date fields should use date-only format (YYYY-MM-DD) instead of full ISO8601 datetime. + * + * Map field names to their desired format: + * - 'date': Serializes as YYYY-MM-DD (e.g., "2025-10-21") + * - 'datetime': Serializes as full ISO8601 (e.g., "2025-10-21T18:18:14.196Z") + * + * Example: + * { dateFormats: { birthDate: 'date', createdAt: 'datetime' } } + * + * @default undefined (all dates use datetime format) + */ + dateFormats?: Record; } type ReturnUseSearchParams = { @@ -433,12 +737,13 @@ type ReturnUseSearchParams = { /** * Schema type for createSearchParamsSchema - * Allows specifying more precise types for arrays and objects + * Allows specifying more precise types for arrays, objects, and dates */ type SchemaTypeConfig = | { type: "string"; default?: string } | { type: "number"; default?: number } | { type: "boolean"; default?: boolean } | { type: "array"; default?: ArrayType[]; arrayType?: ArrayType } - | { type: "object"; default?: ObjectType; objectType?: ObjectType }; + | { type: "object"; default?: ObjectType; objectType?: ObjectType } + | { type: "date"; default?: Date; dateFormat?: "date" | "datetime" }; ```