diff --git a/.changeset/wise-grapes-enjoy.md b/.changeset/wise-grapes-enjoy.md new file mode 100644 index 000000000000..3a83ae9f732e --- /dev/null +++ b/.changeset/wise-grapes-enjoy.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: Add `idPrefix` option in `render`/`mount`/`hydrate` functions diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 4d09d9293fb2..5a85502c8f31 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -96,6 +96,7 @@ export function CallExpression(node, context) { } context.state.analysis.props_id = parent.id; + context.state.analysis.needs_context = true; break; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index cf5ba285cbf3..e84c1574fa93 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -398,7 +398,13 @@ export function client_component(analysis, options) { // we want the cleanup function for the stores to run as the very last thing // so that it can effectively clean up the store subscription even after the user effects runs + // if we have $props.id `should_inject_context` will always be true if (should_inject_context) { + // we need to put the `$props.id` after the `$.push` because the `component_context` will be properly initialized + if (analysis.props_id) { + // need to be placed on first line of the component for hydration + component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id'))); + } component_block.body.unshift(b.stmt(b.call('$.push', ...push_args))); let to_push; @@ -562,11 +568,6 @@ export function client_component(analysis, options) { component_block.body.unshift(b.stmt(b.call('$.check_target', b.id('new.target')))); } - if (analysis.props_id) { - // need to be placed on first line of the component for hydration - component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id'))); - } - if (state.events.size > 0) { body.push( b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name))))) diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 554510542e2e..05e86b2c7146 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -16,6 +16,7 @@ export interface ComponentConstructorOptions< props?: Props; context?: Map; hydrate?: boolean; + idPrefix?: string; intro?: boolean; recover?: boolean; sync?: boolean; @@ -337,6 +338,10 @@ export type MountOptions = Record * @default true */ intro?: boolean; + /** + * Provide a prefix for the generated ID from `$props.id` + */ + idPrefix?: string; } & ({} extends Props ? { /** diff --git a/packages/svelte/src/internal/client/dom/hydration.js b/packages/svelte/src/internal/client/dom/hydration.js index 8523ff97d559..9d743396e6d5 100644 --- a/packages/svelte/src/internal/client/dom/hydration.js +++ b/packages/svelte/src/internal/client/dom/hydration.js @@ -20,6 +20,13 @@ export function set_hydrating(value) { hydrating = value; } +export let id_prefix = ''; + +/** @param {string} v */ +export function set_id_prefix(v) { + id_prefix = v; +} + /** * The node that is currently being hydrated. This starts out as the first node inside the opening * comment, and updates each time a component calls `$.child(...)` or `$.sibling(...)`. diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 575bf55cf62b..71653805b557 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -1,5 +1,5 @@ /** @import { Effect, TemplateNode } from '#client' */ -import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; +import { hydrate_next, hydrate_node, hydrating, id_prefix, set_hydrate_node } from './hydration.js'; import { create_text, get_first_child, is_firefox } from './operations.js'; import { create_fragment_from_html } from './reconciler.js'; import { active_effect } from '../runtime.js'; @@ -264,12 +264,12 @@ export function props_id() { hydrating && hydrate_node && hydrate_node.nodeType === 8 && - hydrate_node.textContent?.startsWith('#s') + hydrate_node.textContent?.startsWith(`#${id_prefix}s`) ) { const id = hydrate_node.textContent.substring(1); hydrate_next(); return id; } - return 'c' + uid++; + return `${id_prefix}c${uid++}`; } diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 3256fe827410..7a7aa1dea196 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -17,7 +17,8 @@ import { hydrate_node, hydrating, set_hydrate_node, - set_hydrating + set_hydrating, + set_id_prefix } from './dom/hydration.js'; import { array_from } from '../shared/utils.js'; import { @@ -86,6 +87,7 @@ export function mount(component, options) { * context?: Map; * intro?: boolean; * recover?: boolean; + * idPrefix?: string; * } : { * target: Document | Element | ShadowRoot; * props: Props; @@ -93,6 +95,7 @@ export function mount(component, options) { * context?: Map; * intro?: boolean; * recover?: boolean; + * idPrefix?: string; * }} options * @returns {Exports} */ @@ -165,7 +168,10 @@ const document_listeners = new Map(); * @param {MountOptions} options * @returns {Exports} */ -function _mount(Component, { target, anchor, props = {}, events, context, intro = true }) { +function _mount( + Component, + { target, anchor, props = {}, events, context, intro = true, idPrefix } +) { init_operations(); var registered_events = new Set(); @@ -209,10 +215,14 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro var anchor_node = anchor ?? target.appendChild(create_text()); branch(() => { - if (context) { + if (context || idPrefix != null) { push({}); var ctx = /** @type {ComponentContext} */ (component_context); - ctx.c = context; + if (context) { + ctx.c = context; + } + + set_id_prefix(idPrefix ? idPrefix + '-' : ''); } if (events) { diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 160a1faa653e..2591dbe4eaab 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -86,9 +86,14 @@ export function element(payload, tag, attributes_fn = noop, children_fn = noop) */ export let on_destroy = []; -function props_id_generator() { +/** + * Creates an ID generator + * @param {string} prefix + * @returns {() => string} + */ +function props_id_generator(prefix) { let uid = 1; - return () => 's' + uid++; + return () => `${prefix}s${uid++}`; } /** @@ -96,11 +101,11 @@ function props_id_generator() { * Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app. * @template {Record} Props * @param {import('svelte').Component | ComponentType>} component - * @param {{ props?: Omit; context?: Map }} [options] + * @param {{ props?: Omit; context?: Map; idPrefix?: string }} [options] * @returns {RenderOutput} */ export function render(component, options = {}) { - const uid = props_id_generator(); + const uid = props_id_generator(options.idPrefix ? options.idPrefix + '-' : ''); /** @type {Payload} */ const payload = { out: '', diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index bb9a5a9c039b..270c377ac9f3 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.js @@ -116,7 +116,8 @@ class Svelte4Component { props, context: options.context, intro: options.intro ?? false, - recover: options.recover + recover: options.recover, + idPrefix: options.idPrefix }); // We don't flushSync for custom element wrappers or if the user doesn't want it diff --git a/packages/svelte/src/server/index.d.ts b/packages/svelte/src/server/index.d.ts index b65ce5bdaa67..d5a3b813e6cb 100644 --- a/packages/svelte/src/server/index.d.ts +++ b/packages/svelte/src/server/index.d.ts @@ -12,10 +12,18 @@ export function render< ...args: {} extends Props ? [ component: Comp extends SvelteComponent ? ComponentType : Comp, - options?: { props?: Omit; context?: Map } + options?: { + props?: Omit; + context?: Map; + idPrefix?: string; + } ] : [ component: Comp extends SvelteComponent ? ComponentType : Comp, - options: { props: Omit; context?: Map } + options: { + props: Omit; + context?: Map; + idPrefix?: string; + } ] ): RenderOutput; diff --git a/packages/svelte/tests/hydration/test.ts b/packages/svelte/tests/hydration/test.ts index 3bf2dd286cd0..dc56c424b353 100644 --- a/packages/svelte/tests/hydration/test.ts +++ b/packages/svelte/tests/hydration/test.ts @@ -1,18 +1,19 @@ // @vitest-environment jsdom +import type { CompileOptions } from '#compiler'; import * as fs from 'node:fs'; -import { assert } from 'vitest'; -import { compile_directory, should_update_expected } from '../helpers.js'; -import { assert_html_equal } from '../html_equal.js'; -import { suite, assert_ok, type BaseTest } from '../suite.js'; +import { flushSync } from 'svelte'; import { createClassComponent } from 'svelte/legacy'; import { render } from 'svelte/server'; -import type { CompileOptions } from '#compiler'; -import { flushSync } from 'svelte'; +import { assert } from 'vitest'; +import { compile_directory } from '../helpers.js'; +import { assert_html_equal } from '../html_equal.js'; +import { assert_ok, suite, type BaseTest } from '../suite.js'; interface HydrationTest extends BaseTest { load_compiled?: boolean; server_props?: Record; + id_prefix?: string; props?: Record; compileOptions?: Partial; /** @@ -50,7 +51,8 @@ const { test, run } = suite(async (config, cwd) => { const head = window.document.head; const rendered = render((await import(`${cwd}/_output/server/main.svelte.js`)).default, { - props: config.server_props ?? config.props ?? {} + props: config.server_props ?? config.props ?? {}, + idPrefix: config?.id_prefix }); const override = read(`${cwd}/_override.html`); @@ -103,7 +105,8 @@ const { test, run } = suite(async (config, cwd) => { component: (await import(`${cwd}/_output/client/main.svelte.js`)).default, target, hydrate: true, - props: config.props + props: config.props, + idPrefix: config?.id_prefix }); console.warn = warn; @@ -164,6 +167,6 @@ const { test, run } = suite(async (config, cwd) => { config.after_test?.(); } }); -export { test, assert_ok }; +export { assert_ok, test }; await run(__dirname); diff --git a/packages/svelte/tests/runtime-browser/assert.js b/packages/svelte/tests/runtime-browser/assert.js index 249d19f80946..fb460c722a0f 100644 --- a/packages/svelte/tests/runtime-browser/assert.js +++ b/packages/svelte/tests/runtime-browser/assert.js @@ -119,6 +119,7 @@ function normalize_children(node) { * skip_mode?: Array<'server' | 'client' | 'hydrate'>; * html?: string; * ssrHtml?: string; + * id_prefix?: string; * props?: Props; * compileOptions?: Partial; * test?: (args: { diff --git a/packages/svelte/tests/runtime-browser/driver-ssr.js b/packages/svelte/tests/runtime-browser/driver-ssr.js index f5f15b64934f..7067e48a1fb9 100644 --- a/packages/svelte/tests/runtime-browser/driver-ssr.js +++ b/packages/svelte/tests/runtime-browser/driver-ssr.js @@ -6,5 +6,5 @@ import config from '__CONFIG__'; import { render } from 'svelte/server'; export default function () { - return render(SvelteComponent, { props: config.props || {} }); + return render(SvelteComponent, { props: config.props || {}, idPrefix: config?.id_prefix }); } diff --git a/packages/svelte/tests/runtime-browser/test-ssr.ts b/packages/svelte/tests/runtime-browser/test-ssr.ts index 2ff1659f802a..6987fac9155a 100644 --- a/packages/svelte/tests/runtime-browser/test-ssr.ts +++ b/packages/svelte/tests/runtime-browser/test-ssr.ts @@ -20,7 +20,7 @@ export async function run_ssr_test( await compile_directory(test_dir, 'server', config.compileOptions); const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default; - const { body } = render(Component, { props: config.props || {} }); + const { body } = render(Component, { props: config.props || {}, idPrefix: config.id_prefix }); fs.writeFileSync(`${test_dir}/_output/rendered.html`, body); diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 3ffb3092a46a..f54ef763e7fa 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -37,6 +37,7 @@ export interface RuntimeTest = Record; props?: Props; server_props?: Props; + id_prefix?: string; before_test?: () => void; after_test?: () => void; test?: (args: { @@ -285,7 +286,8 @@ async function run_test_variant( // ssr into target const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default; const { html, head } = render(SsrSvelteComponent, { - props: config.server_props ?? config.props ?? {} + props: config.server_props ?? config.props ?? {}, + idPrefix: config.id_prefix }); fs.writeFileSync(`${cwd}/_output/rendered.html`, html); @@ -362,7 +364,8 @@ async function run_test_variant( target, props, intro: config.intro, - recover: config.recover ?? false + recover: config.recover ?? false, + idPrefix: config.id_prefix }); } } else { diff --git a/packages/svelte/tests/runtime-runes/samples/props-id-prefix/Child.svelte b/packages/svelte/tests/runtime-runes/samples/props-id-prefix/Child.svelte new file mode 100644 index 000000000000..ad8bbd6f01ff --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id-prefix/Child.svelte @@ -0,0 +1,5 @@ + + +

{id}

diff --git a/packages/svelte/tests/runtime-runes/samples/props-id-prefix/_config.js b/packages/svelte/tests/runtime-runes/samples/props-id-prefix/_config.js new file mode 100644 index 000000000000..fc3b7e416cd8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id-prefix/_config.js @@ -0,0 +1,60 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + id_prefix: 'myPrefix', + test({ assert, target, variant }) { + if (variant === 'dom') { + assert.htmlEqual( + target.innerHTML, + ` + +

myPrefix-c1

+

myPrefix-c2

+

myPrefix-c3

+

myPrefix-c4

+ ` + ); + } else { + assert.htmlEqual( + target.innerHTML, + ` + +

myPrefix-s1

+

myPrefix-s2

+

myPrefix-s3

+

myPrefix-s4

+ ` + ); + } + + let button = target.querySelector('button'); + flushSync(() => button?.click()); + + if (variant === 'dom') { + assert.htmlEqual( + target.innerHTML, + ` + +

myPrefix-c1

+

myPrefix-c2

+

myPrefix-c3

+

myPrefix-c4

+

myPrefix-c5

+ ` + ); + } else { + assert.htmlEqual( + target.innerHTML, + ` + +

myPrefix-s1

+

myPrefix-s2

+

myPrefix-s3

+

myPrefix-s4

+

myPrefix-c1

+ ` + ); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/props-id-prefix/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-id-prefix/main.svelte new file mode 100644 index 000000000000..646bb2ebdefe --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-id-prefix/main.svelte @@ -0,0 +1,19 @@ + + + + +

{id}

+ + + + + +{#if show} + +{/if} diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index f76c5b539f24..3e5753942745 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -15,6 +15,7 @@ import type { CompileOptions } from '#compiler'; interface SSRTest extends BaseTest { compileOptions?: Partial; props?: Record; + id_prefix?: string; withoutNormalizeHtml?: boolean; errors?: string[]; } @@ -33,7 +34,7 @@ const { test, run } = suite(async (config, test_dir) => { const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default; const expected_html = try_read_file(`${test_dir}/_expected.html`); - const rendered = render(Component, { props: config.props || {} }); + const rendered = render(Component, { props: config.props || {}, idPrefix: config.id_prefix }); const { body, head } = rendered; fs.writeFileSync(`${test_dir}/_output/rendered.html`, body); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 4c47661af897..2fd67c837c86 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -13,6 +13,7 @@ declare module 'svelte' { props?: Props; context?: Map; hydrate?: boolean; + idPrefix?: string; intro?: boolean; recover?: boolean; sync?: boolean; @@ -334,6 +335,10 @@ declare module 'svelte' { * @default true */ intro?: boolean; + /** + * Provide a prefix for the generated ID from `$props.id` + */ + idPrefix?: string; } & ({} extends Props ? { /** @@ -485,6 +490,7 @@ declare module 'svelte' { context?: Map; intro?: boolean; recover?: boolean; + idPrefix?: string; } : { target: Document | Element | ShadowRoot; props: Props; @@ -492,6 +498,7 @@ declare module 'svelte' { context?: Map; intro?: boolean; recover?: boolean; + idPrefix?: string; }): Exports; /** * Unmounts a component that was previously mounted using `mount` or `hydrate`. @@ -1875,10 +1882,10 @@ declare module 'svelte/motion' { * const tween = Tween.of(() => number); * * ``` - * + * */ static of(fn: () => U, options?: TweenedOptions | undefined): Tween; - + constructor(value: T, options?: TweenedOptions); /** * Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it. @@ -1897,21 +1904,21 @@ declare module 'svelte/motion' { declare module 'svelte/reactivity' { export class SvelteDate extends Date { - + constructor(...params: any[]); #private; } export class SvelteSet extends Set { - + constructor(value?: Iterable | null | undefined); - + add(value: T): this; #private; } export class SvelteMap extends Map { - + constructor(value?: Iterable | null | undefined); - + set(key: K, value: V): this; #private; } @@ -1921,7 +1928,7 @@ declare module 'svelte/reactivity' { } const REPLACE: unique symbol; export class SvelteURLSearchParams extends URLSearchParams { - + [REPLACE](params: URLSearchParams): void; #private; } @@ -1993,7 +2000,7 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; class ReactiveValue { - + constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; @@ -2058,7 +2065,7 @@ declare module 'svelte/reactivity/window' { get current(): number | undefined; }; class ReactiveValue { - + constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; @@ -2080,11 +2087,19 @@ declare module 'svelte/server' { ...args: {} extends Props ? [ component: Comp extends SvelteComponent ? ComponentType : Comp, - options?: { props?: Omit; context?: Map } + options?: { + props?: Omit; + context?: Map; + idPrefix?: string; + } ] : [ component: Comp extends SvelteComponent ? ComponentType : Comp, - options: { props: Omit; context?: Map } + options: { + props: Omit; + context?: Map; + idPrefix?: string; + } ] ): RenderOutput; interface RenderOutput {