diff --git a/.changeset/pink-goats-promise.md b/.changeset/pink-goats-promise.md new file mode 100644 index 000000000000..fa04e56ef2c2 --- /dev/null +++ b/.changeset/pink-goats-promise.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: introduce types to express bindability diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index c2516a2e5fd1..97dcc364f0b8 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -104,6 +104,32 @@ function handle_compile_error(error, filename, source) { throw error; } +/** + * The parse function parses a component, returning only its abstract syntax tree. + * + * The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST. + * `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7. + * + * https://svelte.dev/docs/svelte-compiler#svelte-parse + * @overload + * @param {string} source + * @param {{ filename?: string; modern: true }} options + * @returns {import('#compiler').Root} + */ + +/** + * The parse function parses a component, returning only its abstract syntax tree. + * + * The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST. + * `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7. + * + * https://svelte.dev/docs/svelte-compiler#svelte-parse + * @overload + * @param {string} source + * @param {{ filename?: string; modern?: false }} [options] + * @returns {import('./types/legacy-nodes.js').LegacyRoot} + */ + /** * The parse function parses a component, returning only its abstract syntax tree. * diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 5239d05f0210..2c1b5287d453 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -1,5 +1,8 @@ // This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are). +import './ambient.js'; +import type { RemoveBindable } from './internal/types.js'; + /** * @deprecated Svelte components were classes in Svelte 4. In Svelte 5, thy are not anymore. * Use `mount` or `createRoot` instead to instantiate components. @@ -18,13 +21,37 @@ export interface ComponentConstructorOptions< $$inline?: boolean; } -// Utility type for ensuring backwards compatibility on a type level: If there's a default slot, add 'children' to the props if it doesn't exist there already -type PropsWithChildren = Props & - (Props extends { children?: any } - ? {} - : Slots extends { default: any } - ? { children?: Snippet } - : {}); +/** Tooling for types uses this for properties are being used with `bind:` */ +export type Binding = { 'bind:': T }; +/** + * Tooling for types uses this for properties that may be bound to. + * Only use this if you author Svelte component type definition files by hand (we recommend using `@sveltejs/package` instead). + * Example: + * ```ts + * export class MyComponent extends SvelteComponent<{ readonly: string, bindable: Bindable }> {} + * ``` + * means you can now do `` + */ +export type Bindable = T | Binding; + +type WithBindings = { + [Key in keyof T]: Bindable; +}; + +/** + * Utility type for ensuring backwards compatibility on a type level: + * - If there's a default slot, add 'children' to the props + * - All props are bindable + */ +type PropsWithChildren = WithBindings & + (Slots extends { default: any } + ? // This is unfortunate because it means "accepts no props" turns into "accepts any prop" + // but the alternative is non-fixable type errors because of the way TypeScript index + // signatures work (they will always take precedence and make an impossible-to-satisfy children type). + Props extends Record + ? any + : { children?: any } + : {}); /** * Can be used to create strongly typed Svelte components. @@ -55,7 +82,7 @@ type PropsWithChildren = Props & * for more info. */ export class SvelteComponent< - Props extends Record = any, + Props extends Record = Record, Events extends Record = any, Slots extends Record = any > { @@ -74,7 +101,7 @@ export class SvelteComponent< * Does not exist at runtime. * ### DO NOT USE! * */ - $$prop_def: PropsWithChildren; + $$prop_def: RemoveBindable; // Without PropsWithChildren: unnecessary, causes type bugs /** * For type checking capabilities only. * Does not exist at runtime. @@ -119,7 +146,7 @@ export class SvelteComponent< * @deprecated Use `SvelteComponent` instead. See TODO for more information. */ export class SvelteComponentTyped< - Props extends Record = any, + Props extends Record = Record, Events extends Record = any, Slots extends Record = any > extends SvelteComponent {} @@ -154,7 +181,7 @@ export type ComponentEvents = * ``` */ export type ComponentProps = - Comp extends SvelteComponent ? Props : never; + Comp extends SvelteComponent ? RemoveBindable : never; /** * Convenience type to get the type of a Svelte component. Useful for example in combination with @@ -226,4 +253,3 @@ export interface EventDispatcher> { } export * from './index-client.js'; -import './ambient.js'; diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index f9c4dbf1a757..c1b1316cca64 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -92,7 +92,7 @@ export function stringify(value) { * @param {{ * target: Document | Element | ShadowRoot; * anchor?: Node; - * props?: Props; + * props?: import('../types.js').RemoveBindable; * events?: { [Property in keyof Events]: (e: Events[Property]) => any }; * context?: Map; * intro?: boolean; @@ -114,7 +114,7 @@ export function mount(component, options) { * @param {import('../../index.js').ComponentType>} component * @param {{ * target: Document | Element | ShadowRoot; - * props?: Props; + * props?: import('../types.js').RemoveBindable; * events?: { [Property in keyof Events]: (e: Events[Property]) => any }; * context?: Map; * intro?: boolean; @@ -181,24 +181,19 @@ export function hydrate(component, options) { } /** - * @template {Record} Props * @template {Record} Exports - * @template {Record} Events - * @param {import('../../index.js').ComponentType>} Component + * @param {import('../../index.js').ComponentType>} Component * @param {{ * target: Document | Element | ShadowRoot; * anchor: Node; - * props?: Props; - * events?: { [Property in keyof Events]: (e: Events[Property]) => any }; + * props?: any; + * events?: any; * context?: Map; * intro?: boolean; * }} options * @returns {Exports} */ -function _mount( - Component, - { target, anchor, props = /** @type {Props} */ ({}), events, context, intro = false } -) { +function _mount(Component, { target, anchor, props = {}, events, context, intro = false }) { init_operations(); const registered_events = new Set(); diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index dc9672bc0dd0..aa2a3321b8f1 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -1,3 +1,4 @@ +import type { Bindable, Binding } from '../../index.js'; import type { Store } from '#shared'; import { STATE_SYMBOL } from './constants.js'; import type { Effect, Source, Value } from './reactivity/types.js'; diff --git a/packages/svelte/src/internal/types.d.ts b/packages/svelte/src/internal/types.d.ts index 12b2e5d4fbab..1508f2ac84a1 100644 --- a/packages/svelte/src/internal/types.d.ts +++ b/packages/svelte/src/internal/types.d.ts @@ -1,2 +1,8 @@ +import type { Bindable } from '../index.js'; + /** Anything except a function */ export type NotFunction = T extends Function ? never : T; + +export type RemoveBindable> = { + [Key in keyof Props]: Props[Key] extends Bindable ? Value : Props[Key]; +}; diff --git a/packages/svelte/tests/types/component.ts b/packages/svelte/tests/types/component.ts index 55477441bb17..affb2f36fbf6 100644 --- a/packages/svelte/tests/types/component.ts +++ b/packages/svelte/tests/types/component.ts @@ -5,7 +5,10 @@ import { type ComponentProps, type ComponentType, mount, - hydrate + hydrate, + type Bindable, + type Binding, + type ComponentConstructorOptions } from 'svelte'; SvelteComponent.element === HTMLElement; @@ -174,3 +177,53 @@ const x: typeof asLegacyComponent = createClassComponent({ hydrate: true, component: NewComponent }); + +// --------------------------------------------------------------------------- bindable + +// Test that +// - everything's bindable unless the component constructor is specifically set telling otherwise (for backwards compatibility) +// - when using mount etc the props are never bindable because this is language-tools only concept + +function binding(value: T): Binding { + return value as any; +} + +class Explicit extends SvelteComponent<{ + foo: string; + bar: Bindable; +}> { + constructor(options: ComponentConstructorOptions<{ foo: string; bar: Bindable }>) { + super(options); + } +} +new Explicit({ target: null as any, props: { foo: 'foo', bar: binding(true) } }); +new Explicit({ target: null as any, props: { foo: 'foo', bar: true } }); +new Explicit({ + target: null as any, + props: { + // @ts-expect-error + foo: binding(''), + bar: true + } +}); +mount(Explicit, { target: null as any, props: { foo: 'foo', bar: true } }); +mount(Explicit, { + target: null as any, + props: { + // @ts-expect-error + bar: binding(true) + } +}); + +class Implicit extends SvelteComponent<{ foo: string; bar: boolean }> {} +new Implicit({ target: null as any, props: { foo: 'foo', bar: true } }); +new Implicit({ target: null as any, props: { foo: binding(''), bar: binding(true) } }); +mount(Implicit, { target: null as any, props: { foo: 'foo', bar: true } }); +mount(Implicit, { + target: null as any, + props: { + foo: 'foo', + // @ts-expect-error + bar: binding(true) + } +}); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5563610b943b..c5c91e67cc54 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1,6 +1,4 @@ declare module 'svelte' { - // This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are). - /** * @deprecated Svelte components were classes in Svelte 4. In Svelte 5, thy are not anymore. * Use `mount` or `createRoot` instead to instantiate components. @@ -19,13 +17,37 @@ declare module 'svelte' { $$inline?: boolean; } - // Utility type for ensuring backwards compatibility on a type level: If there's a default slot, add 'children' to the props if it doesn't exist there already - type PropsWithChildren = Props & - (Props extends { children?: any } - ? {} - : Slots extends { default: any } - ? { children?: Snippet } - : {}); + /** Tooling for types uses this for properties are being used with `bind:` */ + export type Binding = { 'bind:': T }; + /** + * Tooling for types uses this for properties that may be bound to. + * Only use this if you author Svelte component type definition files by hand (we recommend using `@sveltejs/package` instead). + * Example: + * ```ts + * export class MyComponent extends SvelteComponent<{ readonly: string, bindable: Bindable }> {} + * ``` + * means you can now do `` + */ + export type Bindable = T | Binding; + + type WithBindings = { + [Key in keyof T]: Bindable; + }; + + /** + * Utility type for ensuring backwards compatibility on a type level: + * - If there's a default slot, add 'children' to the props + * - All props are bindable + */ + type PropsWithChildren = WithBindings & + (Slots extends { default: any } + ? // This is unfortunate because it means "accepts no props" turns into "accepts any prop" + // but the alternative is non-fixable type errors because of the way TypeScript index + // signatures work (they will always take precedence and make an impossible-to-satisfy children type). + Props extends Record + ? any + : { children?: any } + : {}); /** * Can be used to create strongly typed Svelte components. @@ -56,7 +78,7 @@ declare module 'svelte' { * for more info. */ export class SvelteComponent< - Props extends Record = any, + Props extends Record = Record, Events extends Record = any, Slots extends Record = any > { @@ -75,7 +97,7 @@ declare module 'svelte' { * Does not exist at runtime. * ### DO NOT USE! * */ - $$prop_def: PropsWithChildren; + $$prop_def: RemoveBindable; // Without PropsWithChildren: unnecessary, causes type bugs /** * For type checking capabilities only. * Does not exist at runtime. @@ -120,7 +142,7 @@ declare module 'svelte' { * @deprecated Use `SvelteComponent` instead. See TODO for more information. */ export class SvelteComponentTyped< - Props extends Record = any, + Props extends Record = Record, Events extends Record = any, Slots extends Record = any > extends SvelteComponent {} @@ -155,7 +177,7 @@ declare module 'svelte' { * ``` */ export type ComponentProps = - Comp extends SvelteComponent ? Props : never; + Comp extends SvelteComponent ? RemoveBindable : never; /** * Convenience type to get the type of a Svelte component. Useful for example in combination with @@ -225,6 +247,12 @@ declare module 'svelte' { : [type: Type, parameter: EventMap[Type], options?: DispatchOptions] ): boolean; } + /** Anything except a function */ + type NotFunction = T extends Function ? never : T; + + type RemoveBindable> = { + [Key in keyof Props]: Props[Key] extends Bindable ? Value : Props[Key]; + }; /** * The `onMount` function schedules a callback to run as soon as the component has been mounted to the DOM. * It must be called during the component's initialisation (but doesn't need to live *inside* the component; @@ -295,8 +323,6 @@ declare module 'svelte' { * Synchronously flushes any pending state changes and those that result from it. * */ export function flushSync(fn?: (() => void) | undefined): void; - /** Anything except a function */ - type NotFunction = T extends Function ? never : T; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component * @@ -304,7 +330,7 @@ declare module 'svelte' { export function mount, Exports extends Record, Events extends Record>(component: ComponentType>, options: { target: Document | Element | ShadowRoot; anchor?: Node | undefined; - props?: Props | undefined; + props?: RemoveBindable | undefined; events?: { [Property in keyof Events]: (e: Events[Property]) => any; } | undefined; context?: Map | undefined; intro?: boolean | undefined; @@ -315,7 +341,7 @@ declare module 'svelte' { * */ export function hydrate, Exports extends Record, Events extends Record>(component: ComponentType>, options: { target: Document | Element | ShadowRoot; - props?: Props | undefined; + props?: RemoveBindable | undefined; events?: { [Property in keyof Events]: (e: Events[Property]) => any; } | undefined; context?: Map | undefined; intro?: boolean | undefined; @@ -492,6 +518,18 @@ declare module 'svelte/compiler' { * @param source The component source code * */ export function compileModule(source: string, options: ModuleCompileOptions): CompileResult; + /** + * The parse function parses a component, returning only its abstract syntax tree. + * + * The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST. + * `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7. + * + * https://svelte.dev/docs/svelte-compiler#svelte-parse + * */ + export function parse(source: string, options: { + filename?: string; + modern: true; + }): Root; /** * The parse function parses a component, returning only its abstract syntax tree. * @@ -502,8 +540,8 @@ declare module 'svelte/compiler' { * */ export function parse(source: string, options?: { filename?: string | undefined; - modern?: boolean | undefined; - } | undefined): Root | LegacyRoot; + modern?: false | undefined; + } | undefined): LegacyRoot; /** * @deprecated Replace this with `import { walk } from 'estree-walker'` * */