From 516973c320883b03da371f82d7cc8c63cb123731 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 28 May 2026 20:13:33 +0200 Subject: [PATCH] chore: replace custom renderer type arguments with a single one (#18244) Alternative to #18216 Allows for inference to still works but also allow for explicit declaration in the cases where it's needed. I made some changes to the code proposed by @mrkishi (I used `unique symbol` for the `DefaultType`, inferred the `NodeType`) but overall is mostly his work. I've also replaced the renderer for the test with a `TS` version of it so we can better test the types there. I think this is the best middle ground solution...inference works (when it works...we can document that you need to put the create methods first and the gotchas of automatic inference) but people can also quickly specify their types in an object. --- .../internal/client/custom-renderer/index.js | 11 +-- .../client/custom-renderer/types.d.ts | 24 +++++- .../{renderer.js => renderer.ts} | 80 ++++++++++--------- .../svelte/tests/custom-renderers/shared.ts | 2 +- packages/svelte/types/index.d.ts | 26 +++++- 5 files changed, 94 insertions(+), 49 deletions(-) rename packages/svelte/tests/custom-renderers/{renderer.js => renderer.ts} (76%) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index 8fc51d1f77..9d6fe9e840 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -1,5 +1,5 @@ /** @import { ComponentContext } from '#client' */ -/** @import { Renderer } from "./types.js" */ +/** @import { Renderer, RendererNodes, DefaultNodes } from "./types.js" */ /** @import { Component, ComponentType, SvelteComponent } from '../../../index.js' */ import { boundary } from '../dom/blocks/boundary.js'; import { branch, effect_root } from '../reactivity/effects.js'; @@ -8,10 +8,11 @@ import { push_renderer } from './state.js'; import { get_parent_node, remove_child } from '../dom/operations.js'; /** - * @template {object} [TFragment=object] - * @template {object} [TElement=object] - * @template {object} [TTextNode=object] - * @template {object} [TComment=object] + * @template {RendererNodes} [T=DefaultNodes] + * @template {object} [TFragment=T extends DefaultNodes ? object : T['fragment']] + * @template {object} [TElement=T extends DefaultNodes ? object : T['element']] + * @template {object} [TTextNode=T extends DefaultNodes ? object : T['text']] + * @template {object} [TComment=T extends DefaultNodes ? object : T['comment']] * @param {Renderer} renderer * @returns {Renderer & { render: , Exports extends Record>(component: ComponentType> | Component, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map }) => { component: Exports, unmount: () => void } }} */ diff --git a/packages/svelte/src/internal/client/custom-renderer/types.d.ts b/packages/svelte/src/internal/client/custom-renderer/types.d.ts index 81570b6b78..2d0887619f 100644 --- a/packages/svelte/src/internal/client/custom-renderer/types.d.ts +++ b/packages/svelte/src/internal/client/custom-renderer/types.d.ts @@ -1,5 +1,3 @@ -export type NodeType = 'fragment' | 'element' | 'text' | 'comment'; - export type Renderer< TFragment extends object = object, TElement extends object = object, @@ -86,3 +84,25 @@ export type Renderer< /** Remove an event listener of the given type and handler from the target node. */ removeEventListener(target: TElement, type: string, handler: any, options?: any): void; }; + +export type RendererNodes< + Fragment extends object, + Element extends object, + TextNode extends object, + Comment extends object +> = { + fragment: Fragment; + element: Element; + text: TextNode; + comment: Comment; +}; + +export type NodeType = keyof RendererNodes; + +// to detect if the user is passing a type or not we create this type utils that adds a unique symbol +// that the user will never be able to pass in. We then create a a DefaultNodes type that is used as the default +// type for the T generic of `createRenderer`. This means we can "detect" if the user is passing a type manually by +// checking if the type extends DefaultNodes and using different default values +// for the other arguments (TFragment, TElement, TTextNode, TComment) +export type UnsetObject = object & { readonly __unset: unique symbol }; +export type DefaultNodes = RendererNodes; diff --git a/packages/svelte/tests/custom-renderers/renderer.js b/packages/svelte/tests/custom-renderers/renderer.ts similarity index 76% rename from packages/svelte/tests/custom-renderers/renderer.js rename to packages/svelte/tests/custom-renderers/renderer.ts index a92161d4f6..674ac510e6 100644 --- a/packages/svelte/tests/custom-renderers/renderer.js +++ b/packages/svelte/tests/custom-renderers/renderer.ts @@ -8,20 +8,29 @@ import { createRenderer } from '../../src/renderer/index.js'; -/** - * @typedef {{ type: 'element', name: string, attributes: Record, children: ObjNode[], listeners: Record>, parent: ObjNode | null }} ObjElement - * @typedef {{ type: 'text', value: string, parent: ObjNode | null }} ObjText - * @typedef {{ type: 'comment', value: string, parent: ObjNode | null, before: (node: any)=> void }} ObjComment - * @typedef {{ type: 'fragment', children: ObjNode[], parent: ObjNode | null }} ObjFragment - * @typedef {ObjElement | ObjText | ObjComment | ObjFragment} ObjNode - */ - -/** - * @param {ObjNode & { children?: ObjNode[] }} parent - * @param {ObjNode} node - * @param {ObjNode | null} anchor - */ -function insert_node(parent, node, anchor) { +type ObjElement = { + type: 'element'; + name: string; + attributes: Record; + children: ObjNode[]; + listeners: Record>; + parent: ObjNode | null; +}; +type ObjText = { type: 'text'; value: string; parent: ObjNode | null }; +type ObjComment = { + type: 'comment'; + value: string; + parent: ObjNode | null; + before: (node: any) => void; +}; +type ObjFragment = { type: 'fragment'; children: ObjNode[]; parent: ObjNode | null }; +type ObjNode = ObjElement | ObjText | ObjComment | ObjFragment; + +function insert_node( + parent: ObjNode & { children?: ObjNode[] }, + node: ObjNode, + anchor: ObjNode | null +) { if (node.type === 'fragment') { const children = [...(node.children ?? [])]; for (const child of children) { @@ -35,7 +44,7 @@ function insert_node(parent, node, anchor) { remove_from_parent(node); } - const children = /** @type {ObjNode[]} */ (parent.children); + const children: ObjNode[] = parent.children ?? []; node.parent = parent; if (anchor === null) { @@ -47,8 +56,7 @@ function insert_node(parent, node, anchor) { } } -/** @param {ObjNode} node */ -function remove_from_parent(node) { +function remove_from_parent(node: ObjNode) { const parent = node.parent; if (!parent || !('children' in parent)) return; @@ -61,41 +69,43 @@ function remove_from_parent(node) { children.splice(idx, 1); } -/** - * @type {Array} - */ -export const dom_elements = []; +export const dom_elements: Array = []; -const renderer = createRenderer({ +const renderer = createRenderer<{ + fragment: ObjFragment; + element: ObjElement; + text: ObjText; + comment: ObjComment; +}>({ createFragment() { - return /** @type {ObjFragment} */ ({ + return { type: 'fragment', children: [], parent: null - }); + }; }, createElement(name) { - return /** @type {ObjElement} */ ({ + return { type: 'element', name, attributes: {}, children: [], listeners: {}, parent: null - }); + }; }, createTextNode(data) { - return /** @type {ObjText} */ ({ + return { type: 'text', value: data, parent: null - }); + }; }, createComment(data) { - return /** @type {ObjComment} */ ({ + return { type: 'comment', value: data, parent: null, @@ -104,7 +114,7 @@ const renderer = createRenderer({ before(node) { dom_elements.push(node); } - }); + }; }, nodeType(node) { @@ -186,7 +196,6 @@ const renderer = createRenderer({ /** * Create a root object for mounting components into. - * @returns {ObjFragment} */ export function create_root() { return renderer.createFragment(); @@ -194,11 +203,8 @@ export function create_root() { /** * Dispatch a synthetic event on an object node. - * @param {any} node - * @param {string} type - * @param {any} [detail] */ -export function dispatch_event(node, type, detail) { +export function dispatch_event(node: any, type: string, detail?: any) { const listeners = node.listeners?.[type]; if (!listeners) return; const event = { type, detail, target: node }; @@ -209,10 +215,8 @@ export function dispatch_event(node, type, detail) { /** * Serialize an object tree to an HTML-like string for easy assertion. - * @param {ObjNode} node - * @returns {string} */ -export function serialize(node) { +export function serialize(node: ObjNode): string { if (!node) return ''; switch (node.type) { diff --git a/packages/svelte/tests/custom-renderers/shared.ts b/packages/svelte/tests/custom-renderers/shared.ts index 8a269eb9ba..1ad0f49ecf 100644 --- a/packages/svelte/tests/custom-renderers/shared.ts +++ b/packages/svelte/tests/custom-renderers/shared.ts @@ -38,7 +38,7 @@ const console_log = console.log; // eslint-disable-next-line no-console const console_warn = console.warn; -const renderer_path = path.resolve(import.meta.dirname, 'renderer.js'); +const renderer_path = path.resolve(import.meta.dirname, 'renderer.ts'); export function custom_renderer_suite() { return suite_with_variants( diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 9c2e2ba2b9..dd264d3239 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2568,7 +2568,7 @@ declare module 'svelte/reactivity/window' { } declare module 'svelte/renderer' { - export function createRenderer(renderer: Renderer): Renderer & { + export function createRenderer = DefaultNodes, TFragment extends object = T extends DefaultNodes ? object : T["fragment"], TElement extends object = T extends DefaultNodes ? object : T["element"], TTextNode extends object = T extends DefaultNodes ? object : T["text"], TComment extends object = T extends DefaultNodes ? object : T["comment"]>(renderer: Renderer): Renderer & { render: , Exports extends Record>(component: ComponentType> | Component, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment; props?: Props; @@ -2582,8 +2582,6 @@ declare module 'svelte/renderer' { unmount: () => void; }; }; - type NodeType = 'fragment' | 'element' | 'text' | 'comment'; - type Renderer< TFragment extends object = object, TElement extends object = object, @@ -2670,6 +2668,28 @@ declare module 'svelte/renderer' { /** Remove an event listener of the given type and handler from the target node. */ removeEventListener(target: TElement, type: string, handler: any, options?: any): void; }; + + type RendererNodes< + Fragment extends object, + Element extends object, + TextNode extends object, + Comment extends object + > = { + fragment: Fragment; + element: Element; + text: TextNode; + comment: Comment; + }; + + type NodeType = keyof RendererNodes; + + // to detect if the user is passing a type or not we create this type utils that adds a unique symbol + // that the user will never be able to pass in. We then create a a DefaultNodes type that is used as the default + // type for the T generic of `createRenderer`. This means we can "detect" if the user is passing a type manually by + // checking if the type extends DefaultNodes and using different default values + // for the other arguments (TFragment, TElement, TTextNode, TComment) + type UnsetObject = object & { readonly __unset: unique symbol }; + type DefaultNodes = RendererNodes; /** * @deprecated In Svelte 4, components are classes. In Svelte 5, they are functions. * Use `mount` instead to instantiate components.