diff --git a/.changeset/salty-steaks-wash.md b/.changeset/salty-steaks-wash.md new file mode 100644 index 000000000000..393f4636448e --- /dev/null +++ b/.changeset/salty-steaks-wash.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: custom renderer api diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 3f37be269de7..1ceda6273f99 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -201,6 +201,12 @@ Failed to hydrate the application Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}` ``` +### invalid_snippet_in_custom_renderer + +``` +`createRawSnippet` cannot be used with a custom renderer +``` + ### lifecycle_legacy_only ``` @@ -225,6 +231,12 @@ Rest element properties of `$props()` such as `%property%` are readonly The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files ``` +### snippet_renderer_mismatch + +``` +A snippet created in a component with a custom renderer cannot be rendered by a different renderer +``` + ### state_descriptors_fixed ``` diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 02e762dc486b..2ec8985c66a0 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -617,6 +617,12 @@ Cannot use `await` in deriveds and template expressions, or at the top level of Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case ``` +### incompatible_with_custom_renderer + +``` +%message% is not compatible with `customRenderer` +``` + ### inspect_trace_generator ``` @@ -1125,6 +1131,12 @@ A component can only have one `<%name%>` element Valid `` tag names are %list% ``` +### svelte_options_customrenderer_disabled + +``` +`customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled +``` + ### svelte_options_deprecated_tag ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 85bdd6801066..4ca15f80438b 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -155,6 +155,10 @@ This can happen if you render a hydratable on the client that was not rendered o > Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}` +## invalid_snippet_in_custom_renderer + +> `createRawSnippet` cannot be used with a custom renderer + ## lifecycle_legacy_only > `%name%(...)` cannot be used in runes mode @@ -171,6 +175,10 @@ This can happen if you render a hydratable on the client that was not rendered o > The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files +## snippet_renderer_mismatch + +> A snippet created in a component with a custom renderer cannot be rendered by a different renderer + ## state_descriptors_fixed > Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`. diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 6d1ae6ccbfbf..7fdb56ce559a 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -271,6 +271,10 @@ The same applies to components: > `<%name%>` does not support non-event attributes or spread attributes +## incompatible_with_custom_renderer + +> %message% is not compatible with `customRenderer` + ## js_parse_error > %message% @@ -425,6 +429,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro > Valid `` tag names are %list% +## svelte_options_customrenderer_disabled + +> `customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled + ## svelte_options_deprecated_tag > "tag" option is deprecated — use "customElement" instead diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 53e5516bf65a..c27984e13e4c 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -59,6 +59,9 @@ "./internal/disclose-version": { "default": "./src/internal/disclose-version.js" }, + "./internal/init-operations": { + "default": "./src/internal/init-operations.js" + }, "./internal/flags/async": { "default": "./src/internal/flags/async.js" }, @@ -91,6 +94,10 @@ "types": "./types/index.d.ts", "default": "./src/reactivity/window/index.js" }, + "./renderer": { + "types": "./types/index.d.ts", + "default": "./src/renderer/index.js" + }, "./server": { "types": "./types/index.d.ts", "default": "./src/server/index.js" diff --git a/packages/svelte/renderer.d.ts b/packages/svelte/renderer.d.ts new file mode 100644 index 000000000000..a1a0ce52237a --- /dev/null +++ b/packages/svelte/renderer.d.ts @@ -0,0 +1 @@ +import './types/index.js'; diff --git a/packages/svelte/scripts/check-treeshakeability.js b/packages/svelte/scripts/check-treeshakeability.js index e883496fe2a7..d66b4c062ace 100644 --- a/packages/svelte/scripts/check-treeshakeability.js +++ b/packages/svelte/scripts/check-treeshakeability.js @@ -59,6 +59,7 @@ for (const key in pkg.exports) { if (key === './internal/disclose-version') continue; if (key === './internal/flags/legacy') continue; if (key === './internal/flags/tracing') continue; + if (key === './internal/init-operations') continue; for (const type of ['browser', 'default']) { if (!pkg.exports[key][type]) continue; diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 0ee6004d4a2c..77d3c0718aea 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -8,7 +8,16 @@ const pkg = JSON.parse(fs.readFileSync(`${dir}/package.json`, 'utf-8')); // For people not using moduleResolution: 'bundler', we need to generate these files. Think about removing this in Svelte 6 or 7 // It may look weird, but the imports MUST be ending with index.js to be properly resolved in all TS modes -for (const name of ['action', 'animate', 'easing', 'motion', 'store', 'transition', 'legacy']) { +for (const name of [ + 'action', + 'animate', + 'easing', + 'motion', + 'store', + 'transition', + 'legacy', + 'renderer' +]) { fs.writeFileSync(`${dir}/${name}.d.ts`, "import './types/index.js';\n"); } @@ -44,6 +53,7 @@ await createBundle({ [`${pkg.name}/motion`]: `${dir}/src/motion/public.d.ts`, [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`, [`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`, + [`${pkg.name}/renderer`]: `${dir}/src/internal/client/custom-renderer/index.js`, [`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`, [`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`, [`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`, diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index ccb7fe833ee6..f43e8cae63be 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1194,6 +1194,16 @@ export function illegal_element_attribute(node, name) { e(node, 'illegal_element_attribute', `\`<${name}>\` does not support non-event attributes or spread attributes\nhttps://svelte.dev/e/illegal_element_attribute`); } +/** + * %message% is not compatible with `customRenderer` + * @param {null | number | NodeLike} node + * @param {string} message + * @returns {never} + */ +export function incompatible_with_custom_renderer(node, message) { + e(node, 'incompatible_with_custom_renderer', `${message} is not compatible with \`customRenderer\`\nhttps://svelte.dev/e/incompatible_with_custom_renderer`); +} + /** * %message% * @param {null | number | NodeLike} node @@ -1539,6 +1549,15 @@ export function svelte_meta_invalid_tag(node, list) { e(node, 'svelte_meta_invalid_tag', `Valid \`\` tag names are ${list}\nhttps://svelte.dev/e/svelte_meta_invalid_tag`); } +/** + * `customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function svelte_options_customrenderer_disabled(node) { + e(node, 'svelte_options_customrenderer_disabled', `\`customRenderer\` cannot be set in \`\` unless the \`experimental.customRenderer\` compiler option is enabled\nhttps://svelte.dev/e/svelte_options_customrenderer_disabled`); +} + /** * "tag" option is deprecated — use "customElement" instead * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index 599206ac6a70..6ae91a267e9a 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -10,6 +10,7 @@ import { analyze_component, analyze_module } from './phases/2-analyze/index.js'; import { transform_component, transform_module } from './phases/3-transform/index.js'; import { validate_component_options, validate_module_options } from './validate-options.js'; import * as state from './state.js'; +import * as e from './errors.js'; export { default as preprocess } from './preprocess/index.js'; export { print } from './print/index.js'; @@ -28,7 +29,37 @@ export function compile(source, options) { let parsed = _parse(source); - const { customElement: customElementOptions, ...parsed_options } = parsed.options || {}; + const { + customElement: customElementOptions, + customRenderer: custom_renderer, + ...parsed_options + } = parsed.options || {}; + + // resolve the per-component custom renderer, taking `` + // into account. The normalized option is always a function returning `string | null | undefined` + // (see `validate-options.js`). A string opts in to a specific renderer module, `null` + // opts out to plain DOM (while keeping the feature enabled) and an absent option inherits whatever + // the global option resolves to. + let custom_renderer_option = validated.experimental.customRenderer; + + if (custom_renderer !== undefined) { + // the feature is a global compiler option — a component can only override the renderer it uses + // (or opt out) when `experimental.customRenderer` is enabled. Otherwise nothing pushes a + // renderer, so allowing `` would silently do the wrong thing. + if (!options.experimental?.customRenderer && parsed.options?.attributes) { + for (const attribute of parsed.options.attributes) { + if (attribute.name === 'customRenderer') { + e.svelte_options_customrenderer_disabled(attribute); + } + } + } + + if (typeof custom_renderer === 'string') { + custom_renderer_option = () => custom_renderer; + } else if (custom_renderer === null) { + custom_renderer_option = () => null; + } + } /** @type {ValidatedCompileOptions} */ const combined_options = { @@ -36,7 +67,11 @@ export function compile(source, options) { ...parsed_options, customElementOptions, css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css, - runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes + runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes, + experimental: { + ...validated.experimental, + customRenderer: custom_renderer_option + } }; if (parsed.metadata.ts) { diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index 3baef3ecae05..76c061a275d6 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -149,7 +149,8 @@ export function migrate(source, { filename, use_ts } = {}) { css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external', runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined, experimental: { - async: true + async: true, + customRenderer: () => undefined } }; diff --git a/packages/svelte/src/compiler/phases/1-parse/read/options.js b/packages/svelte/src/compiler/phases/1-parse/read/options.js index 2677fb3b6115..b8369816f5e1 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/options.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/options.js @@ -36,6 +36,26 @@ export default function read_options(node) { e.svelte_options_deprecated_tag(attribute); break; // eslint doesn't know this is unnecessary } + case 'customRenderer': { + const { value } = attribute; + const chunk = Array.isArray(value) ? value[0] : value; + + if (chunk === true || !chunk || (Array.isArray(value) && value.length !== 1)) { + e.svelte_options_invalid_attribute_value(attribute, 'a string or null'); + } + + if (chunk.type === 'Text') { + component_options.customRenderer = chunk.data; + } else if ( + chunk.expression?.type === 'Literal' && + (typeof chunk.expression.value === 'string' || chunk.expression.value === null) + ) { + component_options.customRenderer = chunk.expression.value; + } else { + e.svelte_options_invalid_attribute_value(attribute, 'a string or null'); + } + break; + } case 'customElement': { /** @type {AST.SvelteOptions['customElement']} */ const ce = {}; @@ -193,6 +213,17 @@ export default function read_options(node) { } } + if ( + component_options.css === 'injected' && + typeof component_options.customRenderer === 'string' + ) { + // Find the css attribute node for the error position + const css_attribute = node.attributes.find( + (/** @type {any} */ a) => a.type === 'Attribute' && a.name === 'css' + ); + e.incompatible_with_custom_renderer(css_attribute ?? node, "`css: 'injected'`"); + } + return component_options; } diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index bb13b586d702..fcad97773ddf 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -470,6 +470,14 @@ export function analyze_component(root, source, options) { const custom_element_from_option = options.customElement({ filename: options.filename }); const css = options.css({ filename: options.filename }); + const custom_renderer = options.experimental.customRenderer?.({ filename: options.filename }); + + // only an actual renderer module (a string) is incompatible with injected css — a `null` + // renderer means the component renders to the DOM, which is fine + if (css === 'injected' && typeof custom_renderer === 'string') { + e.incompatible_with_custom_renderer(null, "`css: 'injected'`"); + } + const custom_element = options.customElementOptions ?? custom_element_from_option; const is_custom_element = !!options.customElementOptions || custom_element_from_option; @@ -479,7 +487,8 @@ export function analyze_component(root, source, options) { component_name: name, dev: options.dev, rootDir: options.rootDir, - runes + runes, + custom_renderer: custom_renderer }); // TODO remove all the ?? stuff, we don't need it now that we're validating the config diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AnimateDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AnimateDirective.js index 3b4f7007be71..8c5b1a6953fa 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AnimateDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AnimateDirective.js @@ -1,12 +1,17 @@ /** @import { Context } from '../types' */ /** @import { AST } from '#compiler'; */ import * as e from '../../../errors.js'; +import { custom_renderer } from '../../../state.js'; /** * @param {AST.AnimateDirective} node * @param {Context} context */ export function AnimateDirective(node, context) { + if (custom_renderer) { + e.incompatible_with_custom_renderer(node, '`animate:`'); + } + context.next({ ...context.state, expression: node.metadata.expression }); if (node.metadata.expression.has_await) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 2b7d63660633..2563eb6dcd79 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -2,6 +2,7 @@ /** @import { Context } from '../types' */ import { cannot_be_set_statically, can_delegate_event } from '../../../../utils.js'; import { get_attribute_chunks, is_event_attribute } from '../../../utils/ast.js'; +import { custom_renderer } from '../../../state.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; /** @@ -59,8 +60,11 @@ export function Attribute(node, context) { context.state.analysis.uses_event_attributes = true; } - node.metadata.delegated = - parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2)); + // we can't delegate event handlers in a non dom environment + if (!custom_renderer) { + node.metadata.delegated = + parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2)); + } } } } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js index 559681cacd0f..a5cc56841bbb 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js @@ -12,6 +12,7 @@ import { binding_properties } from '../../bindings.js'; import fuzzymatch from '../../../utils/fuzzymatch.js'; import { is_content_editable_binding, is_svg } from '../../../../utils.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; +import { custom_renderer } from '../../../state.js'; /** * @param {AST.BindDirective} node @@ -27,6 +28,9 @@ export function BindDirective(node, context) { parent?.type === 'SvelteDocument' || parent?.type === 'SvelteBody' ) { + if (custom_renderer) { + e.incompatible_with_custom_renderer(node, '`bind:`'); + } if (node.name in binding_properties) { const property = binding_properties[node.name]; if (property.valid_elements && !property.valid_elements.includes(parent.name)) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ExpressionTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ExpressionTag.js index 88fe4e6afaee..adc0d2d91d7e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ExpressionTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ExpressionTag.js @@ -2,6 +2,7 @@ /** @import { Context } from '../types' */ import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js'; import * as e from '../../../errors.js'; +import { custom_renderer } from '../../../state.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; /** @@ -11,7 +12,7 @@ import { mark_subtree_dynamic } from './shared/fragment.js'; export function ExpressionTag(node, context) { const in_template = context.path.at(-1)?.type === 'Fragment'; - if (in_template && context.state.parent_element) { + if (in_template && context.state.parent_element && !custom_renderer) { const message = is_tag_valid_with_parent('#text', context.state.parent_element); if (message) { e.node_invalid_placement(node, message); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js index 7b0e501760f0..55b038410530 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js @@ -2,12 +2,17 @@ /** @import { Context } from '../types' */ import { mark_subtree_dynamic } from './shared/fragment.js'; import { validate_opening_tag } from './shared/utils.js'; +import * as e from '../../../errors.js'; +import { custom_renderer } from '../../../state.js'; /** * @param {AST.HtmlTag} node * @param {Context} context */ export function HtmlTag(node, context) { + if (custom_renderer) { + e.incompatible_with_custom_renderer(node, '`@html`'); + } if (context.state.analysis.runes) { validate_opening_tag(node, context.state, '@'); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/OnDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/OnDirective.js index b8a0d95671fb..f3f9c105b471 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/OnDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/OnDirective.js @@ -1,6 +1,8 @@ /** @import { AST } from '#compiler' */ /** @import { Context } from '../types' */ +import * as e from '../../../errors.js'; import * as w from '../../../warnings.js'; +import { custom_renderer } from '../../../state.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; /** @@ -8,6 +10,10 @@ import { mark_subtree_dynamic } from './shared/fragment.js'; * @param {Context} context */ export function OnDirective(node, context) { + if (custom_renderer) { + e.incompatible_with_custom_renderer(node, '`on:`'); + } + if (context.state.analysis.runes) { const parent_type = context.path.at(-1)?.type; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/RegularElement.js index bc65bd65db23..03850eb3b763 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/RegularElement.js @@ -17,7 +17,7 @@ import { check_element } from './shared/a11y/index.js'; import { validate_element } from './shared/element.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; import { object } from '../../../utils/ast.js'; -import { runes } from '../../../state.js'; +import { runes, custom_renderer } from '../../../state.js'; /** * @param {AST.RegularElement} node @@ -25,13 +25,16 @@ import { runes } from '../../../state.js'; */ export function RegularElement(node, context) { validate_element(node, context); - check_element(node, context); + + if (!custom_renderer) { + check_element(node, context); + } node.metadata.path = [...context.path]; context.state.analysis.elements.push(node); // Special case: Move the children of + + + + + +
className test
+ + + + + +
accesskey test
+ + + + + +
misplaced scope
+ + +
positive tabindex
+ + +

hidden heading

+ + + + + +
unknown aria
+ + + + + +
orphan figcaption
+ + +distracting + + + + + + + + +

+ + +
+ {#if show} + conditional nesting + {/if} +
+ + + +accesskey on svelte:element + diff --git a/packages/svelte/tests/custom-renderers/samples/on-directive/_config.js b/packages/svelte/tests/custom-renderers/samples/on-directive/_config.js new file mode 100644 index 000000000000..10c8acb2be0b --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/on-directive/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`on:` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/on-directive/main.svelte b/packages/svelte/tests/custom-renderers/samples/on-directive/main.svelte new file mode 100644 index 000000000000..55f36a437f12 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/on-directive/main.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/custom-renderers/samples/out-directive/_config.js b/packages/svelte/tests/custom-renderers/samples/out-directive/_config.js new file mode 100644 index 000000000000..1e35712b9679 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/out-directive/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`out:` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/out-directive/main.svelte b/packages/svelte/tests/custom-renderers/samples/out-directive/main.svelte new file mode 100644 index 000000000000..9b9d6763936c --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/out-directive/main.svelte @@ -0,0 +1,5 @@ + + +
content
diff --git a/packages/svelte/tests/custom-renderers/samples/props-id/Nested.svelte b/packages/svelte/tests/custom-renderers/samples/props-id/Nested.svelte new file mode 100644 index 000000000000..bbed82af1e88 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/props-id/Nested.svelte @@ -0,0 +1,5 @@ + + +{id} \ No newline at end of file diff --git a/packages/svelte/tests/custom-renderers/samples/props-id/_config.js b/packages/svelte/tests/custom-renderers/samples/props-id/_config.js new file mode 100644 index 000000000000..9006b4e2fb6b --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/props-id/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: 'c1 c2' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/props-id/main.svelte b/packages/svelte/tests/custom-renderers/samples/props-id/main.svelte new file mode 100644 index 000000000000..a5e2bf4395e1 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/props-id/main.svelte @@ -0,0 +1,7 @@ + + +{id} + \ No newline at end of file diff --git a/packages/svelte/tests/custom-renderers/samples/raw-snippet/_config.js b/packages/svelte/tests/custom-renderers/samples/raw-snippet/_config.js new file mode 100644 index 000000000000..074c4e38a61e --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/raw-snippet/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + error: + 'A snippet created in a component with a custom renderer cannot be rendered by a different renderer' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/raw-snippet/main.svelte b/packages/svelte/tests/custom-renderers/samples/raw-snippet/main.svelte new file mode 100644 index 000000000000..e1cf5c9b19b1 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/raw-snippet/main.svelte @@ -0,0 +1,9 @@ + + +{@render snippet()} diff --git a/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js b/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js new file mode 100644 index 000000000000..dffe3ba56ded --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '', + test({ assert, target, serialize, dispatch_event }) { + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + assert.ok(button); + + dispatch_event(button, 'click'); + flushSync(); + + const html = serialize(target); + assert.equal(html, ''); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte b/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte new file mode 100644 index 000000000000..ce9ad28ec522 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/custom-renderers/samples/select-option/_config.js b/packages/svelte/tests/custom-renderers/samples/select-option/_config.js new file mode 100644 index 000000000000..7e6e62edd746 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/select-option/_config.js @@ -0,0 +1,29 @@ +import { test } from '../../test'; + +export default test({ + html: '

b

', + test({ assert, target, serialize }) { + const select = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'select' + ); + assert.ok(select); + + // The select element should have a value attribute set via the normal attribute path + assert.equal(select.attributes['value'], 'b'); + + // Each option should have its value as a regular attribute + const options = select.children.filter( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'option' + ); + assert.equal(options.length, 3); + assert.equal(options[0].attributes['value'], 'a'); + assert.equal(options[1].attributes['value'], 'b'); + assert.equal(options[2].attributes['value'], 'c'); + + const html = serialize(target); + assert.equal( + html, + '

b

' + ); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/select-option/main.svelte b/packages/svelte/tests/custom-renderers/samples/select-option/main.svelte new file mode 100644 index 000000000000..9b6e7e9e336d --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/select-option/main.svelte @@ -0,0 +1,10 @@ + + + +

{selected}

diff --git a/packages/svelte/tests/custom-renderers/samples/single-node/Component.svelte b/packages/svelte/tests/custom-renderers/samples/single-node/Component.svelte new file mode 100644 index 000000000000..28b982ee94ee --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/single-node/Component.svelte @@ -0,0 +1,5 @@ + + +{@render children?.()} \ No newline at end of file diff --git a/packages/svelte/tests/custom-renderers/samples/single-node/_config.js b/packages/svelte/tests/custom-renderers/samples/single-node/_config.js new file mode 100644 index 000000000000..6c49c1143079 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/single-node/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: 'test' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/single-node/main.svelte b/packages/svelte/tests/custom-renderers/samples/single-node/main.svelte new file mode 100644 index 000000000000..b63b46834cfc --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/single-node/main.svelte @@ -0,0 +1,5 @@ + + +test diff --git a/packages/svelte/tests/custom-renderers/samples/snippet/_config.js b/packages/svelte/tests/custom-renderers/samples/snippet/_config.js new file mode 100644 index 000000000000..fd2a9d2db5e9 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/snippet/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
hello default
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte b/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte new file mode 100644 index 000000000000..46e069c2154c --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte @@ -0,0 +1,12 @@ + + +{#snippet greeting(name)} + hello {name} +{/snippet} + +
+ {@render greeting(label)} +
diff --git a/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js new file mode 100644 index 000000000000..4b839e5ad2a2 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js @@ -0,0 +1,71 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target, dispatch_event }) { + // Find all inputs and the button + const inputs = target.children.filter( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'input' + ); + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + + assert.equal(inputs.length, 5); + assert.ok(button); + + // Input 1: value="hello" + const input_value = inputs[0]; + assert.equal(input_value.attributes['value'], 'hello'); + assert.equal(input_value.attributes['class'], 'hello'); + assert.equal(input_value.attributes['style'], 'color: blue'); + assert.equal(input_value.attributes['autofocus'], 'true'); + assert.equal(input_value.attributes['muted'], 'true'); + + // Input 2: type="checkbox" checked="" + const input_checked = inputs[1]; + assert.equal(input_checked.attributes['type'], 'checkbox'); + assert.equal(input_checked.attributes['checked'], ''); + + // Input 3: value="fixed" defaultValue="default_val" + const input_default_value = inputs[2]; + assert.equal(input_default_value.attributes['value'], 'fixed'); + assert.equal(input_default_value.attributes['defaultValue'], 'default_val'); + + // Input 4: type="checkbox" checked="" defaultChecked="" + const input_default_checked = inputs[3]; + assert.equal(input_default_checked.attributes['type'], 'checkbox'); + assert.equal(input_default_checked.attributes['checked'], ''); + assert.equal(input_default_checked.attributes['defaultChecked'], ''); + + // Input 5: spread attributes + const input_spread = inputs[4]; + assert.equal(input_spread.attributes['value'], 'hello'); + assert.equal(input_spread.attributes['class'], 'hello'); + assert.equal(input_spread.attributes['style'], 'color: blue'); + + // Click the button to update all values + dispatch_event(button, 'click'); + flushSync(); + + // After update: + // Input 1: value="world" + assert.equal(input_value.attributes['value'], 'world'); + assert.equal(input_value.attributes['class'], 'world'); + assert.equal(input_value.attributes['style'], 'color: red'); + // Input 2: checked should be removed + assert.equal(input_checked.attributes['checked'], undefined); + + // Input 3: defaultValue="new_default", value still "fixed" + assert.equal(input_default_value.attributes['value'], 'fixed'); + assert.equal(input_default_value.attributes['defaultValue'], 'new_default'); + + // Input 4: defaultChecked should be removed + assert.equal(input_default_checked.attributes['defaultChecked'], undefined); + + // Input 5: spread attributes should update value to "world" + assert.equal(input_spread.attributes['value'], 'world'); + assert.equal(input_spread.attributes['class'], 'world'); + assert.equal(input_spread.attributes['style'], 'color: red'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/special-attributes/main.svelte b/packages/svelte/tests/custom-renderers/samples/special-attributes/main.svelte new file mode 100644 index 000000000000..2371fcac8be3 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/special-attributes/main.svelte @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-body/_config.js b/packages/svelte/tests/custom-renderers/samples/svelte-body/_config.js new file mode 100644 index 000000000000..0756c1c39740 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-body/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-body/main.svelte b/packages/svelte/tests/custom-renderers/samples/svelte-body/main.svelte new file mode 100644 index 000000000000..d1377e8ca065 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-body/main.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-document/_config.js b/packages/svelte/tests/custom-renderers/samples/svelte-document/_config.js new file mode 100644 index 000000000000..4ec406039a5b --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-document/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-document/main.svelte b/packages/svelte/tests/custom-renderers/samples/svelte-document/main.svelte new file mode 100644 index 000000000000..f0dc6c56334d --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-document/main.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js new file mode 100644 index 000000000000..8d4602f7fbf7 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js @@ -0,0 +1,18 @@ +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + // If we got here, the component mounted without crashing on document.body access. + // Verify autofocus is set as a regular attribute. + const input = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'input' + ); + assert.ok(input, 'input element should exist'); + assert.equal( + input.attributes['autofocus'], + 'true', + 'autofocus should be set as a regular attribute' + ); + assert.equal(input.attributes['value'], 'test', 'value should be set as a regular attribute'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte new file mode 100644 index 000000000000..bea9f28141b3 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-head/_config.js b/packages/svelte/tests/custom-renderers/samples/svelte-head/_config.js new file mode 100644 index 000000000000..3a5c5d34cbcd --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-head/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-head/main.svelte b/packages/svelte/tests/custom-renderers/samples/svelte-head/main.svelte new file mode 100644 index 000000000000..cd354a4c0736 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-head/main.svelte @@ -0,0 +1,9 @@ + + + + + diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-window/_config.js b/packages/svelte/tests/custom-renderers/samples/svelte-window/_config.js new file mode 100644 index 000000000000..f84268ca2ef4 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-window/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-window/main.svelte b/packages/svelte/tests/custom-renderers/samples/svelte-window/main.svelte new file mode 100644 index 000000000000..47dd4baa2302 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-window/main.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/custom-renderers/samples/template/_config.js b/packages/svelte/tests/custom-renderers/samples/template/_config.js new file mode 100644 index 000000000000..d72579dd362f --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/template/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/template/main.svelte b/packages/svelte/tests/custom-renderers/samples/template/main.svelte new file mode 100644 index 000000000000..302809a562fa --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/template/main.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/_config.js b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/_config.js new file mode 100644 index 000000000000..68e35f6a6631 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '

hello world

' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/main.svelte b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/main.svelte new file mode 100644 index 000000000000..2d4188f2de64 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/main.svelte @@ -0,0 +1,5 @@ + + +

hello {name}

diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression-standalone/_config.js b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone/_config.js new file mode 100644 index 000000000000..b5933e44248a --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: 'hello world' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression-standalone/main.svelte b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone/main.svelte new file mode 100644 index 000000000000..bc43d58894ba --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression-standalone/main.svelte @@ -0,0 +1,5 @@ + + +hello {name} diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js b/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js new file mode 100644 index 000000000000..68e35f6a6631 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '

hello world

' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte b/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte new file mode 100644 index 000000000000..afca058e7028 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte @@ -0,0 +1,6 @@ + + +

hello {name}

diff --git a/packages/svelte/tests/custom-renderers/samples/transition-directive/_config.js b/packages/svelte/tests/custom-renderers/samples/transition-directive/_config.js new file mode 100644 index 000000000000..70f59a5886a6 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/transition-directive/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`transition:` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/transition-directive/main.svelte b/packages/svelte/tests/custom-renderers/samples/transition-directive/main.svelte new file mode 100644 index 000000000000..021567e97c16 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/transition-directive/main.svelte @@ -0,0 +1,5 @@ + + +
content
diff --git a/packages/svelte/tests/custom-renderers/samples/validate-snippet-args/_config.js b/packages/svelte/tests/custom-renderers/samples/validate-snippet-args/_config.js new file mode 100644 index 000000000000..d2a1910b7128 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/validate-snippet-args/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + html: 'world' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/validate-snippet-args/main.svelte b/packages/svelte/tests/custom-renderers/samples/validate-snippet-args/main.svelte new file mode 100644 index 000000000000..2ca772fea30d --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/validate-snippet-args/main.svelte @@ -0,0 +1,5 @@ +{#snippet greet(name)} + {name} +{/snippet} + +{@render greet("world")} \ No newline at end of file diff --git a/packages/svelte/tests/custom-renderers/shared.ts b/packages/svelte/tests/custom-renderers/shared.ts new file mode 100644 index 000000000000..2593f8eb83f9 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/shared.ts @@ -0,0 +1,261 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setImmediate } from 'node:timers/promises'; +import { assert } from 'vitest'; +import { compile_directory } from '../helpers.js'; +import { suite_with_variants, type BaseTest } from '../suite.js'; +import type { CompileOptions } from '#compiler'; +import renderer, { create_root, serialize, dispatch_event, dom_elements } from './renderer.js'; +import { mount, unmount } from '../../src/index-client.js'; +import { writeFile } from 'node:fs/promises'; +import { globSync } from 'tinyglobby'; + +// `_config.js` test callbacks rely on inferred parameter types, which +// TypeScript treats as non-explicit and rejects for chai's assertion-function +// signatures (TS2775). Override the assertion methods we use with +// non-assertion equivalents; runtime behavior is unchanged. +type NonAssertingMethods = { + ok(value: unknown, message?: string): void; + isOk(value: unknown, message?: string): void; + isTrue(value: unknown, message?: string): void; + isFalse(value: unknown, message?: string): void; + exists(value: unknown, message?: string): void; + notExists(value: unknown, message?: string): void; + instanceOf(value: unknown, type: Function, message?: string): void; +}; + +type Assert = Omit & NonAssertingMethods; + +export interface CustomRendererTest extends BaseTest { + html?: string; + compileOptions?: Partial; + props?: Record; + context?: Map; + error?: string; + compile_error?: string; + compile_warnings?: false; + runtime_error?: string; + warnings?: string[]; + test?: (args: { + assert: Assert; + target: any; + component: Record; + mod: any; + logs: any[]; + warnings: any[]; + renderer: typeof renderer; + serialize: typeof serialize; + dispatch_event: typeof dispatch_event; + dom_elements: Array; + }) => void | Promise; +} + +// eslint-disable-next-line no-console +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.ts'); + +export function custom_renderer_suite() { + return suite_with_variants( + ['custom-renderer'], + (_variant, _config) => { + return false; + }, + (config, cwd) => { + return common_setup(cwd, config); + }, + async (config, cwd, _variant, common) => { + if (common === null) { + // compile_error was expected and matched — test passed + return; + } + if (config.compile_error) { + // compile_error was expected but common_setup didn't throw + assert.fail('Expected a compile error'); + } + await run_test(cwd, config, common); + } + ); +} + +async function common_setup( + cwd: string, + config: CustomRendererTest +): Promise { + const compile_options: CompileOptions = { + generate: 'client', + rootDir: cwd, + runes: true, + ...config.compileOptions, + experimental: { + ...config.compileOptions?.experimental, + customRenderer: renderer_path + } + }; + + try { + await compile_directory(cwd, 'client', compile_options); + } catch (err) { + if (config.compile_error) { + assert.include((err as Error).message, config.compile_error); + return null; + } + throw err; + } + + if (config.compile_warnings === false) { + const output_dir = `${cwd}/_output/client`; + const warning_files = globSync('**/*.warnings.json', { cwd: output_dir }); + + for (const file of warning_files) { + const warnings = JSON.parse(fs.readFileSync(path.join(output_dir, file), 'utf-8')); + assert.deepEqual( + warnings, + [], + `Expected no compile warnings in ${file}, got: ${warnings.map((/** @type {any} */ w: any) => w.code).join(', ')}` + ); + } + } + + return compile_options; +} + +async function run_test(cwd: string, config: CustomRendererTest, compile_options: CompileOptions) { + let unintended_error = false; + let logs: any[] = []; + let warnings: any[] = []; + + { + const str = config.test?.toString() ?? ''; + let n = 0; + let i = 0; + while (i < str.length) { + if (str[i] === '(') n++; + if (str[i] === ')' && --n === 0) break; + i++; + } + + if (str.slice(0, i).includes('logs')) { + // eslint-disable-next-line no-console + console.log = (...args) => { + logs.push(...args); + }; + } + + if (str.slice(0, i).includes('warnings') || config.warnings) { + // eslint-disable-next-line no-console + console.warn = (...args) => { + if (typeof args[0] === 'string' && args[0].startsWith('%c[svelte]')) { + let message = args[0]; + message = message.slice(message.indexOf('%c', 2) + 2); + const lines = message.split('\n'); + if (lines.at(-1)?.startsWith('https://svelte.dev/e/')) { + lines.pop(); + } + message = lines.join('\n'); + warnings.push(message); + } else { + warnings.push(...args); + } + }; + } + } + + try { + const mod = await import(`${cwd}/_output/client/main.svelte.js`); + const target = create_root(); + + let component: Record | undefined; + try { + dom_elements.length = 0; + component = mount(mod.default, { + renderer, + target, + props: config.props ?? {}, + context: config.context + }); + } catch (err) { + if (config.error) { + assert.include((err as Error).message, config.error); + return; + } + throw err; + } + + writeFile( + path.join(cwd, '_output/client/output.json'), + JSON.stringify( + target, + (key, value) => { + if (key === 'parent') return undefined; + return value; + }, + '\t' + ) + ); + + if (config.error) { + unintended_error = true; + assert.fail('Expected a runtime error'); + } + + if (config.html) { + const html = serialize(target); + assert.equal(html, config.html); + } + + try { + if (config.test) { + await config.test({ + assert, + target, + component: component ?? {}, + mod, + logs, + warnings, + renderer: renderer, + serialize, + dispatch_event, + dom_elements + }); + } + + if (config.runtime_error) { + unintended_error = true; + assert.fail('Expected a runtime error'); + } + } finally { + if (component) { + await unmount(component); + } + + if (config.warnings) { + assert.deepEqual(warnings, config.warnings); + } + + // After unmount the target should be empty (only comments remain, which serialize to '') + const remaining = serialize(target); + assert.equal(remaining, '', 'Expected component to leave nothing behind after unmount'); + } + } catch (err) { + if (config.runtime_error) { + assert.include((err as Error).message, config.runtime_error); + } else if (config.error && !unintended_error) { + assert.include((err as Error).message, config.error); + } else { + throw err; + } + } finally { + await setImmediate(); + console.log = console_log; + console.warn = console_warn; + } +} + +export function ok(value: any): asserts value { + if (!value) { + throw new Error(`Expected truthy value, got ${value}`); + } +} diff --git a/packages/svelte/tests/custom-renderers/style-string.test.ts b/packages/svelte/tests/custom-renderers/style-string.test.ts new file mode 100644 index 000000000000..830dc53259a0 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/style-string.test.ts @@ -0,0 +1,135 @@ +import { afterEach, assert, beforeEach, describe, it } from 'vitest'; +import { + style_set_property, + style_remove_property +} from '../../src/internal/client/dom/operations.js'; +import { set_renderer } from '../../src/internal/client/custom-renderer/state.js'; + +function make_element(style = '') { + return { attributes: { style } as Record }; +} + +const fake_renderer = { + getAttribute(element: any, name: string) { + return element.attributes[name] ?? null; + }, + setAttribute(element: any, name: string, value: string) { + element.attributes[name] = String(value); + } +} as any; + +function style_of(element: any) { + return element.attributes.style; +} + +describe('custom renderer style string manipulation', () => { + beforeEach(() => { + set_renderer(fake_renderer); + }); + + afterEach(() => { + set_renderer(null); + }); + + it('sets a property on an empty style', () => { + const el = make_element(); + style_set_property(el as any, 'color', 'red'); + assert.equal(style_of(el), 'color: red'); + }); + + it('updates an existing property, preserving unrelated ones', () => { + const el = make_element('color: red; background: blue'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'color: green; background: blue'); + }); + + it('appends a new property, preserving unrelated ones', () => { + const el = make_element('color: red'); + style_set_property(el as any, 'background', 'blue'); + assert.equal(style_of(el), 'color: red; background: blue'); + }); + + it('supports the important priority', () => { + const el = make_element('color: red'); + style_set_property(el as any, 'color', 'green', 'important'); + assert.equal(style_of(el), 'color: green !important'); + }); + + it('removes a property, preserving unrelated ones', () => { + const el = make_element('color: red; background: blue'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), 'background: blue'); + }); + + it('removing a non-existent property is a no-op', () => { + const el = make_element('color: red'); + style_remove_property(el as any, 'background'); + assert.equal(style_of(el), 'color: red'); + }); + + it('removing the only property yields an empty style', () => { + const el = make_element('color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), ''); + }); + + describe('preserves quoted semicolons', () => { + it('when updating an unrelated property', () => { + const el = make_element('content: "a;b"; color: red'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'content: "a;b"; color: green'); + }); + + it('when removing an unrelated property', () => { + const el = make_element('content: "a;b"; color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), 'content: "a;b"'); + }); + + it('with single-quoted values', () => { + const el = make_element("content: 'x;y;z'; color: red"); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), "content: 'x;y;z'; color: green"); + }); + }); + + describe('preserves data URLs', () => { + it('with semicolons inside url(...)', () => { + const el = make_element('background: url(data:image/png;base64,AAAA); color: red'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'background: url(data:image/png;base64,AAAA); color: green'); + }); + + it('when removing the data URL property', () => { + const el = make_element('background: url(data:image/png;base64,AAAA); color: red'); + style_remove_property(el as any, 'background'); + assert.equal(style_of(el), 'color: red'); + }); + }); + + describe('preserves escaped quotes', () => { + it('escaped quote inside a quoted value does not close the string', () => { + const el = make_element('content: "a\\";b"; color: red'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'content: "a\\";b"; color: green'); + }); + + it('escaped backslash before a quote', () => { + const el = make_element('content: "a\\\\"; color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), 'content: "a\\\\"'); + }); + }); + + it('ignores semicolons inside comments', () => { + const el = make_element('color: red /* a;b */; background: blue'); + style_set_property(el as any, 'background', 'green'); + assert.equal(style_of(el), 'color: red /* a;b */; background: green'); + }); + + it('preserves escaped semicolons outside strings', () => { + const el = make_element('--text: a\\;b; color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), '--text: a\\;b'); + }); +}); diff --git a/packages/svelte/tests/custom-renderers/test-dom.test.ts b/packages/svelte/tests/custom-renderers/test-dom.test.ts new file mode 100644 index 000000000000..1029614bc475 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/test-dom.test.ts @@ -0,0 +1,8 @@ +// @vitest-environment jsdom +import { custom_renderer_suite, ok } from './shared'; + +const { test, run } = custom_renderer_suite(); + +export { test, ok }; + +await run(__dirname, 'samples-dom'); diff --git a/packages/svelte/tests/custom-renderers/test.ts b/packages/svelte/tests/custom-renderers/test.ts new file mode 100644 index 000000000000..f29cb0e32238 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/test.ts @@ -0,0 +1,8 @@ +// @vitest-environment node +import { custom_renderer_suite, ok } from './shared'; + +const { test, run } = custom_renderer_suite(); + +export { test, ok }; + +await run(__dirname); diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index de6749c0e762..b89b4a93e163 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -514,14 +514,16 @@ async function run_test_variant( }; } else { run_hydratables_init(); - const render = variant === 'hydrate' ? hydrate : mount; - instance = render(mod.default, { + const options = { target, props, intro: config.intro, recover: config.recover ?? false, transformError: config.transformError - }); + }; + + instance = + variant === 'hydrate' ? hydrate(mod.default, options) : mount(mod.default, options); } } else { run_hydratables_init(); diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js index 41ab26212cd0..5cb6f5215630 100644 --- a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/client/index.svelte.js index 6f1c40988d90..57d8be52310d 100644 --- a/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-each-fallback-hoisting/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/client/index.svelte.js index 4045ad4bf4b6..8c622e065dd6 100644 --- a/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-each-hoisting/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js index 52d92ba531a1..cf56405efa07 100644 --- a/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-if-alternate-hoisting/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js index ca99d1c0c535..4e9cda98ef10 100644 --- a/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-if-chain/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js index e65609dca147..6aa00ac844cb 100644 --- a/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-if-hoisting/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js index 327dc4918c30..9aacfe48bb59 100644 --- a/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-in-derived/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js index 8fb09fadd296..6d2925c6047d 100644 --- a/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-top-level-group-sync-run/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js index c5b700ffec03..d16d90b30bef 100644 --- a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js index 7097ec24d94e..e1ebda1b1e84 100644 --- a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js index a87a356d580b..1e1236b923fd 100644 --- a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; import TextInput from './Child.svelte'; diff --git a/packages/svelte/tests/snapshot/samples/bind-this/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/bind-this/_expected/client/index.svelte.js index dfd32a04e51d..4d4eebd4d6a2 100644 --- a/packages/svelte/tests/snapshot/samples/bind-this/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/bind-this/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js index c9725d6718dd..338330bcebb5 100644 --- a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_config.js b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_config.js new file mode 100644 index 000000000000..135918fc82fb --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_config.js @@ -0,0 +1,13 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + experimental: { + // The actual renderer module is irrelevant for this snapshot — we only + // care that the server output is a no-op while the client output still + // imports/uses the custom renderer. Using a fixed path keeps the + // snapshot stable across machines. + customRenderer: 'my-custom-renderer' + } + } +}); diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_expected/client/main.svelte.js new file mode 100644 index 000000000000..9486146c319b --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_expected/client/main.svelte.js @@ -0,0 +1,16 @@ +import $renderer from 'my-custom-renderer'; +import 'svelte/internal/disclose-version'; +import * as $ from 'svelte/internal/client'; + +var root = $.from_tree([['div', { class: 'greeting' }, ' ']]); + +export default function Main($$anchor, $$props) { + var $$pop_renderer = $.push_renderer($renderer); + let name = $.prop($$props, 'name', 3, 'world'); + var div = root(); + var text = $.only_child(div); + + $.template_effect(() => $.set_text(text, `Hello ${name() ?? ''}!`)); + $.append($$anchor, div); + $$pop_renderer(); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_expected/server/main.svelte.js b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_expected/server/main.svelte.js new file mode 100644 index 000000000000..2b19de8d8688 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/_expected/server/main.svelte.js @@ -0,0 +1 @@ +export default function Main() {} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/main.svelte b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/main.svelte new file mode 100644 index 000000000000..f54c1e4c8146 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-server-noop/main.svelte @@ -0,0 +1,5 @@ + + +
Hello {name}!
diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js index d0d474fa293e..c8c670249679 100644 --- a/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js index d2f2f87f2072..630d9874a481 100644 --- a/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index 799c9f2139a9..98c8d8e16496 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js index 049d47a96f77..fe4f245169b3 100644 --- a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js index c0626bd416c9..d35c1840d424 100644 --- a/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js index 762a23754c9b..4c2fd824bd83 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js index 792d5421e1be..285696ff9063 100644 --- a/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js index 68fdaa4570cc..a1748bfadca2 100644 --- a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js index 66d1a5af5a4a..ecaea720777c 100644 --- a/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js index 0eab38919c5e..b5a24a220648 100644 --- a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js index 8fe9663290cb..d340709f4425 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js index 7df616f694b8..94e3606901dc 100644 --- a/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js index a351851875ed..36b0160de84f 100644 --- a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/Option.svelte.js b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/Option.svelte.js index a4b18a011bb2..e551ef81c72d 100644 --- a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/Option.svelte.js +++ b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/Option.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js index 36b774340bb4..6a54871023d2 100644 --- a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js index 30976b40e654..831b2777ee32 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js index f3272f041467..b19c9add230d 100644 --- a/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js index 2270005ee0dd..78b15fcaee80 100644 --- a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js index a6a38f475c5e..10d81914ead6 100644 --- a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/snapshot/samples/typescript-optional-parameter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/typescript-optional-parameter/_expected/client/index.svelte.js index 3593b6c65ef0..664c25de6bd0 100644 --- a/packages/svelte/tests/snapshot/samples/typescript-optional-parameter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/typescript-optional-parameter/_expected/client/index.svelte.js @@ -1,3 +1,4 @@ +import 'svelte/internal/init-operations'; import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; diff --git a/packages/svelte/tests/types/component.ts b/packages/svelte/tests/types/component.ts index 06749e993776..533e8f1cbef1 100644 --- a/packages/svelte/tests/types/component.ts +++ b/packages/svelte/tests/types/component.ts @@ -9,6 +9,7 @@ import { type Component, type ComponentInternals } from 'svelte'; +import { createRenderer } from 'svelte/renderer'; import { render } from 'svelte/server'; SvelteComponent.element === HTMLElement; @@ -291,6 +292,48 @@ mount( // if component receives no args, props can be omitted mount(null as any as Component<{}>, { target: null as any }); +type CustomFragment = { kind: 'fragment' }; +type CustomElement = { kind: 'element' }; +type CustomText = { kind: 'text' }; +type CustomComment = { kind: 'comment' }; +const custom_renderer = createRenderer<{ + fragment: CustomFragment; + element: CustomElement; + text: CustomText; + comment: CustomComment; +}>(null as any); + +mount(functionComponent, { + renderer: custom_renderer, + target: null as any as CustomFragment, + anchor: null as any as CustomComment, + props: { + binding: true, + readonly: 'foo' + } +}); + +mount(functionComponent, { + renderer: custom_renderer, + // @ts-expect-error target must match the renderer + target: null as any as HTMLElement, + props: { + binding: true, + readonly: 'foo' + } +}); + +mount(functionComponent, { + renderer: custom_renderer, + target: null as any as CustomElement, + // @ts-expect-error anchor must match the renderer + anchor: null as any as CustomFragment, + props: { + binding: true, + readonly: 'foo' + } +}); + hydrate(functionComponent, { target: null as any as Document | Element | ShadowRoot, props: { diff --git a/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/_config.js b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/_config.js new file mode 100644 index 000000000000..f47bee71df87 --- /dev/null +++ b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/errors.json b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/errors.json new file mode 100644 index 000000000000..d1bf4544579e --- /dev/null +++ b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "svelte_options_customrenderer_disabled", + "message": "`customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled", + "start": { + "line": 1, + "column": 16 + }, + "end": { + "line": 1, + "column": 43 + } + } +] diff --git a/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/input.svelte b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/input.svelte new file mode 100644 index 000000000000..cf7cf1a5b012 --- /dev/null +++ b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/input.svelte @@ -0,0 +1,3 @@ + + +
hello
diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index bab587ace3b1..4efcccc873d1 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -22,6 +22,7 @@ "svelte/internal/client": ["./src/internal/client/index.js"], "svelte/legacy": ["./src/legacy/legacy-client.js"], "svelte/motion": ["./src/motion/public.d.ts"], + "svelte/renderer": ["./src/renderer/index.js"], "svelte/server": ["./src/server/index.d.ts"], "svelte/store": ["./src/store/public.d.ts"], "svelte/reactivity": ["./src/reactivity/index-client.js"] diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 661b8403547b..d3109b6f46ef 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -315,15 +315,52 @@ declare module 'svelte' { /** * Defines the options accepted by the `mount()` function. */ - export type MountOptions = Record> = { - /** - * Target element where the component will be mounted. - */ - target: Document | Element | ShadowRoot; - /** - * Optional node inside `target`. When specified, it is used to render the component immediately before it. - */ - anchor?: Node; + type MountRenderer = { + createFragment(): object; + createElement(name: string): object; + createTextNode(data: string): object; + createComment(data: string): object; + }; + + type MountRendererTarget = Renderer extends { + createFragment(): infer TFragment; + createElement(name: string): infer TElement; + } + ? TFragment | TElement + : never; + + type MountRendererAnchor = Renderer extends { + createElement(name: string): infer TElement; + createTextNode(data: string): infer TTextNode; + createComment(data: string): infer TComment; + } + ? TElement | TTextNode | TComment + : never; + + export type MountOptions< + Props extends Record = Record, + Renderer = undefined + > = (Renderer extends MountRenderer + ? { + /** Custom renderer to use instead of the DOM. */ + renderer: Renderer; + /** Target node where the component will be mounted. */ + target: MountRendererTarget; + /** Optional node inside `target`. When specified, it is used to render the component immediately before it. */ + anchor?: MountRendererAnchor; + } + : { + /** + * Target element where the component will be mounted. + */ + target: Document | Element | ShadowRoot; + /** + * Optional node inside `target`. When specified, it is used to render the component immediately before it. + */ + anchor?: Node; + /** Custom renderer to use instead of the DOM. */ + renderer?: undefined; + }) & { /** * Allows the specification of events. * @deprecated Use callback props instead. @@ -344,18 +381,18 @@ declare module 'svelte' { */ transformError?: (error: unknown) => unknown | Promise; } & ({} extends Props - ? { - /** - * Component properties. - */ - props?: Props; - } - : { - /** - * Component properties. - */ - props: Props; - }); + ? { + /** + * Component properties. + */ + props?: Props; + } + : { + /** + * Component properties. + */ + props: Props; + }); /** * Represents work that is happening off-screen, such as data being preloaded @@ -539,7 +576,7 @@ declare module 'svelte' { * Transitions will play during the initial render unless the `intro` option is set to `false`. * * */ - export function mount, Exports extends Record>(component: ComponentType> | Component, options: MountOptions): Exports; + export function mount, Exports extends Record, Renderer = undefined>(component: ComponentType> | Component, options: MountOptions): Exports; /** * Hydrates a component on the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component * @@ -1108,7 +1145,7 @@ declare module 'svelte/compiler' { */ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** - * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. + * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `globalThis.__svelte.v`. * * @default true */ @@ -1198,6 +1235,19 @@ declare module 'svelte/compiler' { * @since 5.36 */ async?: boolean; + /** + * Enables custom renderers to be specified with ``. Can be: + * + * - `true`, allowing components to individually opt in + * - a string that points to a default custom renderer module. Individual components can override the default, or opt out with `` + * - a function that receives a `{ filename }` object and returns a custom renderer module path, or `null` if no custom renderer should be used + * + * A custom renderer module's default export must be an object created with `createRenderer`. + */ + customRenderer?: + | boolean + | string + | ((options: { filename: string }) => string | null | undefined); }; } /** @@ -1247,6 +1297,7 @@ declare module 'svelte/compiler' { preserveWhitespace?: boolean; namespace?: Namespace; css?: 'injected'; + customRenderer?: string | null; customElement?: { tag?: string; shadow?: 'open' | 'none' | ObjectExpression | undefined; @@ -2682,6 +2733,120 @@ declare module 'svelte/reactivity/window' { export {}; } +declare module 'svelte/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"], R extends Renderer = Renderer>(renderer: R): R; + type Renderer< + TFragment extends object = object, + TElement extends object = object, + TTextNode extends object = object, + TComment extends object = object, + TNode extends TFragment | TElement | TTextNode | TComment = + | TFragment + | TElement + | TTextNode + | TComment + > = { + /** Creates a fragment, a container for multiple nodes. Inserting a fragment should insert all of its children. */ + createFragment(): TFragment; + + /** Creates an element with the given name. */ + createElement(name: string): TElement; + + /** Creates a text node with the given data. */ + createTextNode(data: string): TTextNode; + + /** + * Creates a comment node with the given data. + * This is often used as an anchor for inserting elements; it doesn't necessarily need to be rendered. + */ + createComment(data: string): TComment; + + /** Should return the type of the node in string form. */ + nodeType(node: TNode): NodeType; + + /** + * Return the value of the node: + * - text value of a text node + * - data value of a comment + * - null for elements and fragments + */ + getNodeValue(node: TTextNode | TComment): string | null; + + /** Return the value of the attribute with the given name on the element, or null if it doesn't exist. */ + getAttribute(element: TElement, name: string): string | null; + + /** Set the attribute with the given name and value on the element. */ + setAttribute(element: TElement, key: string, value: any): void; + + /** Remove the attribute with the given name from the element. */ + removeAttribute(element: TElement, name: string): void; + + /** Return true if the element has an attribute with the given name. */ + hasAttribute(element: TElement, name: string): boolean; + + /** + * Set the text content of the node to the given value. + * This should work for both text nodes and elements. + */ + setText(node: TElement | TTextNode | TComment, text: string): void; + + /** Return the first child of the element or fragment, or null if it has no children. */ + getFirstChild(element: TElement | TFragment): TNode | null; + + /** Return the last child of the element or fragment, or null if it has no children. */ + getLastChild(element: TElement | TFragment): TNode | null; + + /** Return the next sibling of the node, or null if it has no next sibling. */ + getNextSibling(node: TElement | TTextNode | TComment): TNode | null; + + /** + * Insert the element into the parent before the anchor. + * If anchor is null, insert at the end. + */ + insert( + parent: TElement | TFragment, + element: TNode, + anchor: TElement | TTextNode | TComment | null + ): void; + + /** Remove the node from the tree. */ + remove(node: TElement | TTextNode | TComment): void; + + /** Return the parent of the element, or null if it has no parent. */ + getParent(element: TElement | TTextNode | TComment): TNode | null; + + /** Add an event listener of the given type and handler to the target node. */ + addEventListener(target: TElement, type: string, handler: any, options?: any): void; + + /** 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; + + export {}; +} + declare module 'svelte/server' { import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte'; /** @@ -3206,7 +3371,7 @@ declare module 'svelte/types/compiler/interfaces' { */ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** - * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. + * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `globalThis.__svelte.v`. * * @default true */ @@ -3296,6 +3461,19 @@ declare module 'svelte/types/compiler/interfaces' { * @since 5.36 */ async?: boolean; + /** + * Enables custom renderers to be specified with ``. Can be: + * + * - `true`, allowing components to individually opt in + * - a string that points to a default custom renderer module. Individual components can override the default, or opt out with `` + * - a function that receives a `{ filename }` object and returns a custom renderer module path, or `null` if no custom renderer should be used + * + * A custom renderer module's default export must be an object created with `createRenderer`. + */ + customRenderer?: + | boolean + | string + | ((options: { filename: string }) => string | null | undefined); }; } /**