Skip to content

Commit

Permalink
feat: provide new Component type that represents the shape of compo…
Browse files Browse the repository at this point in the history
…nents

In Svelte 3 and 4, components were classes under the hood, and the base class was `SvelteComponent`. This class was also used in language tools to properly type check the template code.
In Svelte 5, components are functions. To give people a way to extend them programmatically, it would be good to expose the actual shape of components. This is why this PR introduces a new `Component` type.
For backwards compatibility reasons, we can't just get rid of the old class-based types. We also need to ensure that language tools can work with both the new and old types: There are many libraries out there that provide `d.ts` files with type definitions written using the class types - these should not error.
That's why there's an accompagning language tools PR (sveltejs/language-tools#2380) that's doing the heavy lifting: Instead of generating classes, it now generates a constant and an interfaces and uses Typescript's declaration merging feature to provide both so we can declare a component export as being both a class and a function. That ensures that people can still instantiate them with `new` (which they can do if they use the `legacy.componentApi` compiler option), and it also ensure we don't need to adjust any other code generation mechanisms in language tools yet - from a language tools perspective, classes are still the norm. But through exposing the default export as being _also_ callable as a function we can in a future Svelte version, where classes/the Svelte 4 syntax are removed completely, seamlessly switch over to using functions in the code generation, too, and the `d.ts` files generated up until that point will support it because of the dual shape. This way we have both backwards and forwards compatibility.
  • Loading branch information
dummdidumm committed May 24, 2024
1 parent 7dacf2c commit c9a19e4
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 87 deletions.
5 changes: 5 additions & 0 deletions .changeset/heavy-doors-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

feat: provide `Component` type that represents the new shape of Svelte components
108 changes: 73 additions & 35 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import './ambient.js';

/**
* @deprecated Svelte components were classes in Svelte 4. In Svelte 5, thy are not anymore.
* @deprecated Svelte components were classes in Svelte 4. In Svelte 5, they are not anymore.
* Use `mount` or `createRoot` instead to instantiate components.
* See [breaking changes](https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes)
* for more info.
Expand Down Expand Up @@ -34,32 +34,10 @@ type Properties<Props, Slots> = Props &
: {});

/**
* Can be used to create strongly typed Svelte components.
*
* #### Example:
*
* You have component library on npm called `component-library`, from which
* you export a component called `MyComponent`. For Svelte+TypeScript users,
* you want to provide typings. Therefore you create a `index.d.ts`:
* ```ts
* import { SvelteComponent } from "svelte";
* export class MyComponent extends SvelteComponent<{foo: string}> {}
* ```
* Typing this makes it possible for IDEs like VS Code with the Svelte extension
* to provide intellisense and to use the component like this in a Svelte file
* with TypeScript:
* ```svelte
* <script lang="ts">
* import { MyComponent } from "component-library";
* </script>
* <MyComponent foo={'bar'} />
* ```
*
* This was the base class for Svelte components in Svelte 4. Svelte 5+ components
* are completely different under the hood. You should only use this type for typing,
* not actually instantiate components with `new` - use `mount` or `createRoot` instead.
* See [breaking changes](https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes)
* for more info.
* are completely different under the hood. For typing, use `Component` instead.
* To instantiate components, use `mount` or `createRoot`.
* See [breaking changes documentation](https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes) for more info.
*/
export class SvelteComponent<
Props extends Record<string, any> = Record<string, any>,
Expand All @@ -80,27 +58,25 @@ export class SvelteComponent<
* For type checking capabilities only.
* Does not exist at runtime.
* ### DO NOT USE!
* */
*/
$$prop_def: Props; // Without Properties: unnecessary, causes type bugs
/**
* For type checking capabilities only.
* Does not exist at runtime.
* ### DO NOT USE!
*
* */
*/
$$events_def: Events;
/**
* For type checking capabilities only.
* Does not exist at runtime.
* ### DO NOT USE!
*
* */
*/
$$slot_def: Slots;
/**
* For type checking capabilities only.
* Does not exist at runtime.
* ### DO NOT USE!
* */
*/
$$bindings?: string;

/**
Expand Down Expand Up @@ -129,7 +105,61 @@ export class SvelteComponent<
}

/**
* @deprecated Use `SvelteComponent` instead. See TODO for more information.
* Can be used to create strongly typed Svelte components.
*
* #### Example:
*
* You have component library on npm called `component-library`, from which
* you export a component called `MyComponent`. For Svelte+TypeScript users,
* you want to provide typings. Therefore you create a `index.d.ts`:
* ```ts
* import { Component } from "svelte";
* export declare const MyComponent: Component<{ foo: string }> {}
* ```
* Typing this makes it possible for IDEs like VS Code with the Svelte extension
* to provide intellisense and to use the component like this in a Svelte file
* with TypeScript:
* ```svelte
* <script lang="ts">
* import { MyComponent } from "component-library";
* </script>
* <MyComponent foo={'bar'} />
* ```
*/
export interface Component<
Props extends Record<string, any> = {},
Exports extends Record<string, any> = {},
Bindings extends keyof Props | '' = ''
> {
/**
* @param internal An internal object used by Svelte. Do not use or modify.
* @param props The props passed to the component.
*/
(
internal: unknown,
props: Props
): {
/**
* @deprecated This method only exists when using one of the legacy compatibility helpers, which
* is a stop-gap solution. See https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes
* for more info.
*/
$on?(type: string, callback: (e: any) => void): () => void;
/**
* @deprecated This method only exists when using one of the legacy compatibility helpers, which
* is a stop-gap solution. See https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes
* for more info.
*/
$set?(props: Partial<Props>): void;
} & Exports;
/** The custom element version of the component. Only present if compiled with the `customElement` compiler option */
element?: typeof HTMLElement;
/** Does not exist at runtime, for typing capabilities only. DO NOT USE */
z_$$bindings?: Bindings;
}

/**
* @deprecated Use `Component` instead. See [breaking changes documentation](https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes) for more information.
*/
export class SvelteComponentTyped<
Props extends Record<string, any> = Record<string, any>,
Expand All @@ -138,6 +168,8 @@ export class SvelteComponentTyped<
> extends SvelteComponent<Props, Events, Slots> {}

/**
* @deprecated The new `Component` type does not have a dedicated Events type. Use `ComponentProps` instead.
*
* Convenience type to get the events the given component expects. Example:
* ```html
* <script lang="ts">
Expand Down Expand Up @@ -166,10 +198,16 @@ export type ComponentEvents<Comp extends SvelteComponent> =
* </script>
* ```
*/
export type ComponentProps<Comp extends SvelteComponent> =
Comp extends SvelteComponent<infer Props> ? Props : never;
export type ComponentProps<Comp extends SvelteComponent | Component> =
Comp extends SvelteComponent<infer Props>
? Props
: Comp extends Component<infer Props>
? Props
: never;

/**
* @deprecated This type is obsolete when working with the new `Component` type.
*
* Convenience type to get the type of a Svelte component. Useful for example in combination with
* dynamic components using `<svelte:component>`.
*
Expand Down
12 changes: 5 additions & 7 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,12 @@ export function stringify(value) {
*
* @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<Props>> | import('../../index.js').Component<Props, Exports, any>} component
* @param {{
* target: Document | Element | ShadowRoot;
* anchor?: Node;
* props?: Props;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* events?: Record<string, (e: any) => any>;
* context?: Map<any, any>;
* intro?: boolean;
* }} options
Expand All @@ -111,12 +110,11 @@ export function mount(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<Props>> | import('../../index.js').Component<Props, Exports, any>} component
* @param {{
* target: Document | Element | ShadowRoot;
* props?: Props;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* events?: Record<string, (e: any) => any>;
* context?: Map<any, any>;
* intro?: boolean;
* recover?: boolean;
Expand Down Expand Up @@ -184,7 +182,7 @@ export function hydrate(component, options) {

/**
* @template {Record<string, any>} Exports
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<any>>} Component
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<any>> | import('../../index.js').Component<any>} Component
* @param {{
* target: Document | Element | ShadowRoot;
* anchor: Node;
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/legacy/legacy-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { define_property } from '../internal/client/utils.js';
* @template {Record<string, any>} Slots
*
* @param {import('svelte').ComponentConstructorOptions<Props> & {
* component: import('svelte').ComponentType<import('svelte').SvelteComponent<Props, Events, Slots>>;
* component: import('svelte').ComponentType<import('svelte').SvelteComponent<Props, Events, Slots>> | import('svelte').Component<Props>;
* immutable?: boolean;
* hydrate?: boolean;
* recover?: boolean;
Expand All @@ -36,7 +36,7 @@ export function createClassComponent(options) {
* @template {Record<string, any>} Events
* @template {Record<string, any>} Slots
*
* @param {import('svelte').SvelteComponent<Props, Events, Slots>} component
* @param {import('svelte').SvelteComponent<Props, Events, Slots> | import('svelte').Component<Props>} component
* @returns {import('svelte').ComponentType<import('svelte').SvelteComponent<Props, Events, Slots> & Exports>}
*/
export function asClassComponent(component) {
Expand Down
84 changes: 82 additions & 2 deletions packages/svelte/tests/types/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
type ComponentProps,
type ComponentType,
mount,
hydrate
hydrate,
type Component
} from 'svelte';

SvelteComponent.element === HTMLElement;
Expand Down Expand Up @@ -49,6 +50,15 @@ const legacyComponentEvents2: ComponentEvents<LegacyComponent> = {
event: new KeyboardEvent('click')
};

const legacyComponentInstance: SvelteComponent<{ prop: string }> = new LegacyComponent({
target: null as any as Document | Element | ShadowRoot,
props: {
prop: 'foo'
}
});

const legacyComponentClass: typeof SvelteComponent<{ prop: string }> = LegacyComponent;

// --------------------------------------------------------------------------- new: functions

class NewComponent extends SvelteComponent<
Expand Down Expand Up @@ -130,7 +140,7 @@ hydrate(NewComponent, {
},
events: {
event: (e) =>
// @ts-expect-error
// we're not type checking this as it's an edge case and removing the generic later would be an annoying mini breaking change
e.doesNotExist
},
immutable: true,
Expand Down Expand Up @@ -174,3 +184,73 @@ const x: typeof asLegacyComponent = createClassComponent({
hydrate: true,
component: NewComponent
});

// --------------------------------------------------------------------------- function component

const functionComponent: Component<
{ binding: boolean; readonly: string },
{ foo: 'bar' },
'binding'
> = (a, props) => {
props.binding === true;
props.readonly === 'foo';
// @ts-expect-error
props.readonly = true;
// @ts-expect-error
props.binding = '';
return {
foo: 'bar'
};
};
functionComponent.element === HTMLElement;

functionComponent(null as any, {
binding: true,
// @ts-expect-error
readonly: true
});

const functionComponentInstance = functionComponent(null as any, {
binding: true,
readonly: 'foo',
// @ts-expect-error
x: ''
});
functionComponentInstance.foo === 'bar';
// @ts-expect-error
functionComponentInstance.foo = 'foo';

mount(functionComponent, {
target: null as any as Document | Element | ShadowRoot,
props: {
binding: true,
readonly: 'foo',
// would be nice to error here, probably needs NoInfer type helper in upcoming TS 5.5
x: ''
}
});
mount(functionComponent, {
target: null as any as Document | Element | ShadowRoot,
props: {
binding: true,
// @ts-expect-error wrong type
readonly: 1
}
});

hydrate(functionComponent, {
target: null as any as Document | Element | ShadowRoot,
props: {
binding: true,
readonly: 'foo',
// would be nice to error here, probably needs NoInfer type helper in upcoming TS 5.5
x: ''
}
});
hydrate(functionComponent, {
target: null as any as Document | Element | ShadowRoot,
// @ts-expect-error missing prop
props: {
binding: true
}
});
Loading

0 comments on commit c9a19e4

Please sign in to comment.