diff --git a/.changeset/cold-cheetahs-judge.md b/.changeset/cold-cheetahs-judge.md new file mode 100644 index 0000000000..63302f29e8 --- /dev/null +++ b/.changeset/cold-cheetahs-judge.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: deduplicate children prop and default slot diff --git a/.changeset/famous-grapes-refuse.md b/.changeset/famous-grapes-refuse.md new file mode 100644 index 0000000000..54246e918a --- /dev/null +++ b/.changeset/famous-grapes-refuse.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: provide `isSnippet` function to determine whether a given value is a snippet diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index e5302a3a7c..11b62fa056 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -774,6 +774,13 @@ function serialize_inline_component(node, component_name, context) { */ let slot_scope_applies_to_itself = false; + /** + * Components may have a children prop and also have child nodes. In this case, we assume + * that the child component isn't using render tags yet and pass the slot as $$slots.default. + * We're not doing it for spread attributes, as this would result in too many false positives. + */ + let has_children_prop = false; + /** * @param {import('estree').Property} prop */ @@ -823,6 +830,10 @@ function serialize_inline_component(node, component_name, context) { slot_scope_applies_to_itself = true; } + if (attribute.name === 'children') { + has_children_prop = true; + } + const [, value] = serialize_attribute_value(attribute.value, context); if (attribute.metadata.dynamic) { @@ -944,13 +955,8 @@ function serialize_inline_component(node, component_name, context) { b.block([...(slot_name === 'default' && !slot_scope_applies_to_itself ? lets : []), ...body]) ); - if (slot_name === 'default') { - push_prop( - b.init( - 'children', - context.state.options.dev ? b.call('$.add_snippet_symbol', slot_fn) : slot_fn - ) - ); + if (slot_name === 'default' && !has_children_prop) { + push_prop(b.init('children', b.call('$.add_snippet_symbol', slot_fn))); } else { serialized_slots.push(b.init(slot_name, slot_fn)); } @@ -2685,9 +2691,7 @@ export const template_visitors = { } else { context.state.init.push(b.const(node.expression, b.arrow(args, body))); } - if (context.state.options.dev) { - context.state.init.push(b.stmt(b.call('$.add_snippet_symbol', node.expression))); - } + context.state.init.push(b.stmt(b.call('$.add_snippet_symbol', node.expression))); }, FunctionExpression: function_visitor, ArrowFunctionExpression: function_visitor, @@ -3106,7 +3110,7 @@ export const template_visitors = { ); const expression = is_default - ? b.member(b.id('$$props'), b.id('children')) + ? b.call('$.default_slot', b.id('$$props')) : b.member(b.member(b.id('$$props'), b.id('$$slots')), name, true, true); const slot = b.call('$.slot', context.state.node, expression, props_expression, fallback); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 8ff7d2c316..6946d4362f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -947,6 +947,13 @@ function serialize_inline_component(node, component_name, context) { */ let slot_scope_applies_to_itself = false; + /** + * Components may have a children prop and also have child nodes. In this case, we assume + * that the child component isn't using render tags yet and pass the slot as $$slots.default. + * We're not doing it for spread attributes, as this would result in too many false positives. + */ + let has_children_prop = false; + /** * @param {import('estree').Property} prop */ @@ -975,6 +982,10 @@ function serialize_inline_component(node, component_name, context) { slot_scope_applies_to_itself = true; } + if (attribute.name === 'children') { + has_children_prop = true; + } + const value = serialize_attribute_value(attribute.value, context, false, true); push_prop(b.prop('init', b.key(attribute.name), value)); } else if (attribute.type === 'BindDirective' && attribute.name !== 'this') { @@ -1049,14 +1060,8 @@ function serialize_inline_component(node, component_name, context) { b.block([...(slot_name === 'default' && !slot_scope_applies_to_itself ? lets : []), ...body]) ); - if (slot_name === 'default') { - push_prop( - b.prop( - 'init', - b.id('children'), - context.state.options.dev ? b.call('$.add_snippet_symbol', slot_fn) : slot_fn - ) - ); + if (slot_name === 'default' && !has_children_prop) { + push_prop(b.prop('init', b.id('children'), b.call('$.add_snippet_symbol', slot_fn))); } else { const slot = b.prop('init', b.literal(slot_name), slot_fn); serialized_slots.push(slot); @@ -1614,9 +1619,7 @@ const template_visitors = { ) ); - if (context.state.options.dev) { - context.state.init.push(b.stmt(b.call('$.add_snippet_symbol', node.expression))); - } + context.state.init.push(b.stmt(b.call('$.add_snippet_symbol', node.expression))); }, Component(node, context) { const state = context.state; @@ -1744,7 +1747,7 @@ const template_visitors = { const lets = []; /** @type {import('estree').Expression} */ - let expression = b.member_id('$$props.children'); + let expression = b.call('$.default_slot', b.id('$$props')); for (const attribute of node.attributes) { if (attribute.type === 'SpreadAttribute') { diff --git a/packages/svelte/src/internal/client/dom/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index c07eca4d44..4bb1c1cc57 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -38,3 +38,22 @@ export function snippet(get_snippet, node, ...args) { }; }, block); } + +const snippet_symbol = Symbol.for('svelte.snippet'); + +/** + * @param {any} fn + */ +export function add_snippet_symbol(fn) { + fn[snippet_symbol] = true; + return fn; +} + +/** + * Returns true if given parameter is a snippet. + * @param {any} maybeSnippet + * @returns {maybeSnippet is import('svelte').Snippet} + */ +export function isSnippet(maybeSnippet) { + return /** @type {any} */ (maybeSnippet)?.[snippet_symbol] === true; +} diff --git a/packages/svelte/src/internal/client/dom/legacy/misc.js b/packages/svelte/src/internal/client/dom/legacy/misc.js index a4b52efe28..d137a1068d 100644 --- a/packages/svelte/src/internal/client/dom/legacy/misc.js +++ b/packages/svelte/src/internal/client/dom/legacy/misc.js @@ -1,6 +1,7 @@ import { set, source } from '../../reactivity/sources.js'; import { get } from '../../runtime.js'; import { is_array } from '../../utils.js'; +import { isSnippet } from '../blocks/snippet.js'; /** * Under some circumstances, imports may be reactive in legacy mode. In that case, @@ -66,3 +67,17 @@ export function update_legacy_props($$new_props) { } } } + +/** + * @param {Record} $$props + */ +export function default_slot($$props) { + var children = $$props.$$slots?.default; + if (children) { + return children; + } + children = $$props.children; + if (isSnippet(children)) { + return children; + } +} diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 442d91190b..1dde94a493 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -1,3 +1,4 @@ +import { isSnippet } from './dom/blocks/snippet.js'; import { untrack } from './runtime.js'; import { is_array } from './utils.js'; @@ -103,22 +104,12 @@ export function loop_guard(timeout) { }; } -const snippet_symbol = Symbol.for('svelte.snippet'); - -/** - * @param {any} fn - */ -export function add_snippet_symbol(fn) { - fn[snippet_symbol] = true; - return fn; -} - /** * Validate that the function handed to `{@render ...}` is a snippet function, and not some other kind of function. * @param {any} snippet_fn */ export function validate_snippet(snippet_fn) { - if (snippet_fn && snippet_fn[snippet_symbol] !== true) { + if (snippet_fn && !isSnippet(snippet_fn)) { throw new Error( 'The argument to `{@render ...}` must be a snippet function, not a component or some other kind of function. ' + 'If you want to dynamically render one snippet or another, use `$derived` and pass its result to `{@render ...}`.' @@ -132,7 +123,7 @@ export function validate_snippet(snippet_fn) { * @param {any} component_fn */ export function validate_component(component_fn) { - if (component_fn?.[snippet_symbol] === true) { + if (isSnippet(component_fn)) { throw new Error('A snippet must be rendered with `{@render ...}`'); } return component_fn; diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index a6b0001d33..9ea6b91f4c 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -11,6 +11,8 @@ import { DEV } from 'esm-env'; import { UNINITIALIZED } from '../client/constants.js'; export * from '../client/validate.js'; +export { add_snippet_symbol } from '../client/dom/blocks/snippet.js'; +export { default_slot } from '../client/dom/legacy/misc.js'; /** * @typedef {{ diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index 09f122e370..482628660e 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.js @@ -12,7 +12,7 @@ import * as $ from '../internal/index.js'; * @template {Record} Slots * * @param {import('../main/public.js').ComponentConstructorOptions & { - * component: import('../main/public.js').SvelteComponent; + * component: typeof import('../main/public.js').SvelteComponent; * immutable?: boolean; * hydrate?: boolean; * recover?: boolean; diff --git a/packages/svelte/src/main/main-client.js b/packages/svelte/src/main/main-client.js index 58461692b6..99086b7ab5 100644 --- a/packages/svelte/src/main/main-client.js +++ b/packages/svelte/src/main/main-client.js @@ -181,5 +181,6 @@ export { hasContext, getContext, getAllContexts, - setContext + setContext, + isSnippet } from '../internal/index.js'; diff --git a/packages/svelte/src/main/main-server.js b/packages/svelte/src/main/main-server.js index 58ee20e9ae..d332520a0f 100644 --- a/packages/svelte/src/main/main-server.js +++ b/packages/svelte/src/main/main-server.js @@ -12,7 +12,8 @@ export { tick, unmount, untrack, - createRoot + createRoot, + isSnippet } from './main-client.js'; /** @returns {void} */ diff --git a/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/A.svelte b/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/A.svelte new file mode 100644 index 0000000000..32735e38d6 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/A.svelte @@ -0,0 +1,6 @@ + + +{children} + diff --git a/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/_config.js b/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/_config.js new file mode 100644 index 0000000000..3add2f34f3 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: `foo bar` +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/main.svelte b/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/main.svelte new file mode 100644 index 0000000000..15d898bdd3 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/slot-children-prop/main.svelte @@ -0,0 +1,7 @@ + + + + bar + \ No newline at end of file diff --git a/packages/svelte/tests/types/snippet.ts b/packages/svelte/tests/types/snippet.ts index edc5aba123..75553419b4 100644 --- a/packages/svelte/tests/types/snippet.ts +++ b/packages/svelte/tests/types/snippet.ts @@ -1,4 +1,4 @@ -import type { Snippet } from 'svelte'; +import { type Snippet, isSnippet } from 'svelte'; const return_type: ReturnType = null as any; @@ -38,3 +38,8 @@ const h: Snippet<[{ a: true }]> = (a) => { const i: Snippet = () => { return return_type; }; + +let j = null as any; +if (isSnippet(j)) { + let x: Snippet = j; +} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 567f0b0dcf..4b994010d4 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -291,6 +291,12 @@ declare module 'svelte' { * Anything except a function */ type NotFunction = T extends Function ? never : T; + /** + * Returns true if given parameter is a snippet. + * */ + export function isSnippet(maybeSnippet: any): maybeSnippet is (this: void) => unique symbol & { + _: "functions passed to {@render ...} tags must use the `Snippet` type imported from \"svelte\""; + }; /** * @deprecated Use `mount` or `hydrate` instead */ @@ -1729,7 +1735,17 @@ declare module 'svelte/legacy' { * * */ export function createClassComponent, Exports extends Record, Events extends Record, Slots extends Record>(options: ComponentConstructorOptions & { - component: SvelteComponent; + component: { + new (options: ComponentConstructorOptions unique symbol & { + _: "functions passed to {@render ...} tags must use the `Snippet` type imported from \"svelte\""; + }) | undefined; + } : {})>): SvelteComponent; + }; immutable?: boolean | undefined; hydrate?: boolean | undefined; recover?: boolean | undefined;