diff --git a/.changeset/nice-mugs-mate.md b/.changeset/nice-mugs-mate.md new file mode 100644 index 000000000000..b6c43b0a4abc --- /dev/null +++ b/.changeset/nice-mugs-mate.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: support `exactOptionalPropertyTypes` for optional form schema fields diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 31b39c82a722..7fb12f663edf 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2090,7 +2090,7 @@ type RecursiveFormFields = RemoteFormFieldContainer & { type MaybeArray = T | T[]; export interface RemoteFormInput { - [key: string]: MaybeArray; + [key: string]: MaybeArray | undefined; } export interface RemoteFormIssue { diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index a5afd3c03356..525e096f2259 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -514,7 +514,14 @@ function persist_state() { /** * @param {string | URL} url - * @param {{ replaceState?: boolean; noScroll?: boolean; keepFocus?: boolean; invalidateAll?: boolean; invalidate?: Array boolean)>; state?: Record }} options + * @param {{ + * replaceState?: boolean | undefined; + * noScroll?: boolean | undefined; + * keepFocus?: boolean | undefined; + * invalidateAll?: boolean | undefined; + * invalidate?: Array boolean)> | undefined; + * state?: Record | undefined; + * }} options * @param {number} redirect_count * @param {{}} [nav_token] */ @@ -697,7 +704,7 @@ async function initialize(result, target, hydrate) { page.status = rendering_error.status; return error; } - : undefined + : /** @type {never} */ (undefined) }); // Wait for a microtask in case svelte experimental async is enabled, @@ -731,11 +738,11 @@ async function initialize(result, target, hydrate) { * url: URL; * params: Record; * branch: Array; - * errors?: Array; + * errors?: Array | undefined; * status: number; * error: App.Error | null; * route: import('types').CSRRoute | null; - * form?: Record | null; + * form?: Record | null | undefined; * }} opts */ async function get_navigation_result_from_branch({ @@ -962,7 +969,7 @@ async function load_node({ loader, parent, url, params, route, server_data_node // implement streaming request bodies and/or the body getter body: resource.method === 'GET' || resource.method === 'HEAD' - ? undefined + ? /** @type {never} */ (undefined) : await resource.blob(), cache: resource.cache, credentials: resource.credentials, @@ -971,7 +978,10 @@ async function load_node({ loader, parent, url, params, route, server_data_node // To keep the two values in sync, we explicitly set the headers to `undefined`. // Also, not sure why, but sometimes 0 is evaluated as truthy so we need to // explicitly compare the headers length to a number here - headers: [...resource.headers].length > 0 ? resource?.headers : undefined, + headers: + [...resource.headers].length > 0 + ? resource?.headers + : /** @type {never} */ (undefined), integrity: resource.integrity, keepalive: resource.keepalive, method: resource.method, @@ -1650,16 +1660,16 @@ function _before_navigate({ url, type, intent, delta, event, scroll }) { * state: Record; * scroll: { x: number, y: number }; * delta: number; - * }; - * keepfocus?: boolean; - * noscroll?: boolean; - * replace_state?: boolean; - * state?: Record; - * redirect_count?: number; - * nav_token?: {}; - * accept?: () => void; - * block?: () => void; - * event?: Event + * } | undefined; + * keepfocus?: boolean | undefined; + * noscroll?: boolean | undefined; + * replace_state?: boolean | undefined; + * state?: Record | undefined; + * redirect_count?: number | undefined; + * nav_token?: {} | undefined; + * accept?: (() => void) | undefined; + * block?: (() => void) | undefined; + * event?: Event | undefined; * }} opts */ async function navigate({ diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index f91124f962c7..768258dd441a 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -100,14 +100,14 @@ export type BranchNode = { server: DataNode | null; universal: DataNode | null; data: Record | null; - slash?: TrailingSlash; + slash?: TrailingSlash | undefined; }; export interface DataNode { type: 'data'; data: Record | null; uses: Uses; - slash?: TrailingSlash; + slash?: TrailingSlash | undefined; } export interface NavigationState { diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index c64d0e46fde6..73962192c7bb 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -673,8 +673,8 @@ export interface RemoteFormInternals extends BaseRemoteInternals { export interface RemotePrerenderInternals extends BaseRemoteInternals { type: 'prerender'; has_arg: boolean; - dynamic?: boolean; - inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean | undefined; + inputs?: RemotePrerenderInputsGenerator | undefined; } export type RemoteAnyQueryInternals = diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index 8c44aa4e3d74..780ec6717769 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -10,6 +10,9 @@ import { invalid } from '@sveltejs/kit'; +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/no-floating-promises */ + const schema: StandardSchemaV1 = null as any; const schema2: StandardSchemaV1 = null as any; const schema3: StandardSchemaV1 = null as any; @@ -58,6 +61,23 @@ function query_tests() { } void query_with_optional_arg(); + async function query_with_optional_undefined_arg() { + const q = query( + null as any as StandardSchemaV1<{ a?: string | undefined }>, + () => 'Hello world' + ); + // @ts-expect-error + void q(); + void q({}); + void q({ a: 'hi' }); + void q({ a: undefined }); + // @ts-expect-error + void q({ a: null }); + // @ts-expect-error + void q(1); + } + void query_with_optional_undefined_arg(); + async function query_unsafe() { const q = query('unchecked', (a: number) => a); const result: number = await q(1); @@ -515,6 +535,8 @@ function form_tests() { f6.fields.array[0].array.value(); // @ts-expect-error f6.fields.array[0].array.as('text'); + // @ts-expect-error + f6.input!['array[0].prop'] = 123; // any const f7 = form(null as any, (data, issue) => { @@ -540,8 +562,6 @@ function form_tests() { f8.fields.allIssues(); // @ts-expect-error f8.fields.x; - // @ts-expect-error - f6.input!['array[0].prop'] = 123; // schema with optional array fields (e.g. Zod's `.default([])` produces an input // type where the property is optional, so its value type is `string[] | undefined`). @@ -578,6 +598,26 @@ function form_tests() { // @ts-expect-error f_optional_arrays.fields.files.as('text'); + // schema with optional & value-undefined fields. (e.g. Valibot's `.optional()` + // produces an input type that accepts `undefined` as value, which under + // `exactOptionalPropertyTypes` is treated distinctly from an omitted property.) + const f_optional_undefined_prop = form( + null as any as StandardSchemaV1<{ + strings?: string[] | undefined; + }>, + (data) => { + data.strings?.[0] === 'a'; + return { success: true }; + } + ); + // `.as()` should be available on optional|undefined fields + f_optional_undefined_prop.fields.strings.as('checkbox', 'value'); + f_optional_undefined_prop.fields.strings.as('select multiple'); + // indexed access gives back a typed field + f_optional_undefined_prop.fields.strings[0].as('text'); + // @ts-expect-error + f_optional_undefined_prop.fields.strings.as('number'); + // doesn't use data const f9 = form(() => Promise.resolve({ success: true })); f9.result?.success === true; diff --git a/packages/kit/test/types/tsconfig.json b/packages/kit/test/types/tsconfig.json index 7675e28d941c..2e8a2b2182fb 100644 --- a/packages/kit/test/types/tsconfig.json +++ b/packages/kit/test/types/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": true + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true }, "include": ["**/*.test.ts", "../../src/types/*.d.ts"] } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 1948fe5f4615..8443b401cb69 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2064,7 +2064,7 @@ declare module '@sveltejs/kit' { type MaybeArray = T | T[]; export interface RemoteFormInput { - [key: string]: MaybeArray; + [key: string]: MaybeArray | undefined; } export interface RemoteFormIssue {