Skip to content

feat: Bindable types #11225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

feat: introduce types to express bindability
26 changes: 26 additions & 0 deletions packages/svelte/src/compiler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tangential to this PR but it seems extremely weird to pass in filename here, just so it can be attached to an error object if there's a parse error. i think we should get rid of that

* @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.
*
Expand Down
50 changes: 38 additions & 12 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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, Slots> = 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<T> = { '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<string> }> {}
* ```
* means you can now do `<MyComponent {readonly} bind:bindable />`
*/
export type Bindable<T> = T | Binding<T>;

type WithBindings<T> = {
[Key in keyof T]: Bindable<T[Key]>;
};

/**
* 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<Props, Slots> = WithBindings<Props> &
(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<string, never>
? any
: { children?: any }
: {});

/**
* Can be used to create strongly typed Svelte components.
Expand Down Expand Up @@ -55,7 +82,7 @@ type PropsWithChildren<Props, Slots> = Props &
* for more info.
*/
export class SvelteComponent<
Props extends Record<string, any> = any,
Props extends Record<string, any> = Record<string, any>,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> {
Expand All @@ -74,7 +101,7 @@ export class SvelteComponent<
* Does not exist at runtime.
* ### DO NOT USE!
* */
$$prop_def: PropsWithChildren<Props, Slots>;
$$prop_def: RemoveBindable<Props>; // Without PropsWithChildren: unnecessary, causes type bugs
/**
* For type checking capabilities only.
* Does not exist at runtime.
Expand Down Expand Up @@ -119,7 +146,7 @@ export class SvelteComponent<
* @deprecated Use `SvelteComponent` instead. See TODO for more information.
*/
export class SvelteComponentTyped<
Props extends Record<string, any> = any,
Props extends Record<string, any> = Record<string, any>,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> extends SvelteComponent<Props, Events, Slots> {}
Expand Down Expand Up @@ -154,7 +181,7 @@ export type ComponentEvents<Comp extends SvelteComponent> =
* ```
*/
export type ComponentProps<Comp extends SvelteComponent> =
Comp extends SvelteComponent<infer Props> ? Props : never;
Comp extends SvelteComponent<infer Props> ? RemoveBindable<Props> : never;

/**
* Convenience type to get the type of a Svelte component. Useful for example in combination with
Expand Down Expand Up @@ -226,4 +253,3 @@ export interface EventDispatcher<EventMap extends Record<string, any>> {
}

export * from './index-client.js';
import './ambient.js';
17 changes: 6 additions & 11 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function stringify(value) {
* @param {{
* target: Document | Element | ShadowRoot;
* anchor?: Node;
* props?: Props;
* props?: import('../types.js').RemoveBindable<Props>;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* context?: Map<any, any>;
* intro?: boolean;
Expand All @@ -114,7 +114,7 @@ export function mount(component, options) {
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<Props, Events>>} component
* @param {{
* target: Document | Element | ShadowRoot;
* props?: Props;
* props?: import('../types.js').RemoveBindable<Props>;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* context?: Map<any, any>;
* intro?: boolean;
Expand Down Expand Up @@ -181,24 +181,19 @@ export function hydrate(component, options) {
}

/**
* @template {Record<string, any>} Props
* @template {Record<string, any>} Exports
* @template {Record<string, any>} Events
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<Props, Events>>} Component
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<any>>} 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<any, any>;
* 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();
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/internal/client/types.d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte/src/internal/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
import type { Bindable } from '../index.js';

/** Anything except a function */
export type NotFunction<T> = T extends Function ? never : T;

export type RemoveBindable<Props extends Record<string, any>> = {
[Key in keyof Props]: Props[Key] extends Bindable<infer Value> ? Value : Props[Key];
};
55 changes: 54 additions & 1 deletion packages/svelte/tests/types/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
type ComponentProps,
type ComponentType,
mount,
hydrate
hydrate,
type Bindable,
type Binding,
type ComponentConstructorOptions
} from 'svelte';

SvelteComponent.element === HTMLElement;
Expand Down Expand Up @@ -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<T>(value: T): Binding<T> {
return value as any;
}

class Explicit extends SvelteComponent<{
foo: string;
bar: Bindable<boolean>;
}> {
constructor(options: ComponentConstructorOptions<{ foo: string; bar: Bindable<boolean> }>) {
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)
}
});
Loading