Skip to content

Commit

Permalink
feat: add idPrefix to render (#15428)
Browse files Browse the repository at this point in the history
This ensures someone like Astro can render multiple islands and the unique ids produced in them are able to be deduplicated

---------

Co-authored-by: Hugos68 <[email protected]>
Co-authored-by: paoloricciuti <[email protected]>
Co-authored-by: Simon Holthausen <[email protected]>
  • Loading branch information
4 people authored Mar 4, 2025
1 parent 7ce2dfc commit b82692a
Show file tree
Hide file tree
Showing 16 changed files with 149 additions and 29 deletions.
5 changes: 5 additions & 0 deletions .changeset/wise-grapes-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: Add `idPrefix` option to `render`
1 change: 1 addition & 0 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ComponentConstructorOptions<
intro?: boolean;
recover?: boolean;
sync?: boolean;
idPrefix?: string;
$$inline?: boolean;
}

Expand Down
14 changes: 6 additions & 8 deletions packages/svelte/src/internal/client/dom/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,6 @@ export function append(anchor, dom) {
anchor.before(/** @type {Node} */ (dom));
}

let uid = 1;

export function reset_props_id() {
uid = 1;
}

/**
* Create (or hydrate) an unique UID for the component instance.
*/
Expand All @@ -264,12 +258,16 @@ export function props_id() {
hydrating &&
hydrate_node &&
hydrate_node.nodeType === 8 &&
hydrate_node.textContent?.startsWith('#s')
hydrate_node.textContent?.startsWith(`#`)
) {
const id = hydrate_node.textContent.substring(1);
hydrate_next();
return id;
}

return 'c' + uid++;
// @ts-expect-error This way we ensure the id is unique even across Svelte runtimes
(window.__svelte ??= {}).uid ??= 1;

// @ts-expect-error
return `c${window.__svelte.uid++}`;
}
7 changes: 4 additions & 3 deletions packages/svelte/src/internal/disclose-version.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PUBLIC_VERSION } from '../version.js';

if (typeof window !== 'undefined')
// @ts-ignore
(window.__svelte ||= { v: new Set() }).v.add(PUBLIC_VERSION);
if (typeof window !== 'undefined') {
// @ts-expect-error
((window.__svelte ??= {}).v ??= new Set()).add(PUBLIC_VERSION);
}
13 changes: 9 additions & 4 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,26 @@ 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++}`;
}

/**
* Only available on the server and when compiling with the `server` option.
* 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<string, any>} Props
* @param {import('svelte').Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; 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: '',
Expand Down
12 changes: 10 additions & 2 deletions packages/svelte/src/server/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ export function render<
...args: {} extends Props
? [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options?: { props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }
options?: {
props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
: [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options: { props: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }
options: {
props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
): RenderOutput;
11 changes: 7 additions & 4 deletions packages/svelte/tests/hydration/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import * as fs from 'node:fs';
import { assert } from 'vitest';
import { compile_directory, should_update_expected } from '../helpers.js';
import { compile_directory } from '../helpers.js';
import { assert_html_equal } from '../html_equal.js';
import { suite, assert_ok, type BaseTest } from '../suite.js';
import { assert_ok, suite, type BaseTest } from '../suite.js';
import { createClassComponent } from 'svelte/legacy';
import { render } from 'svelte/server';
import type { CompileOptions } from '#compiler';
Expand All @@ -13,6 +13,7 @@ import { flushSync } from 'svelte';
interface HydrationTest extends BaseTest {
load_compiled?: boolean;
server_props?: Record<string, any>;
id_prefix?: string;
props?: Record<string, any>;
compileOptions?: Partial<CompileOptions>;
/**
Expand Down Expand Up @@ -50,7 +51,8 @@ const { test, run } = suite<HydrationTest>(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`);
Expand Down Expand Up @@ -103,7 +105,8 @@ const { test, run } = suite<HydrationTest>(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;
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/tests/runtime-browser/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompileOptions>;
* test?: (args: {
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/tests/runtime-browser/driver-ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
2 changes: 1 addition & 1 deletion packages/svelte/tests/runtime-browser/test-ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
10 changes: 7 additions & 3 deletions packages/svelte/tests/runtime-legacy/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { setup_html_equal } from '../html_equal.js';
import { raf } from '../animation-helpers.js';
import type { CompileOptions } from '#compiler';
import { suite_with_variants, type BaseTest } from '../suite.js';
import { reset_props_id } from '../../src/internal/client/dom/template.js';

type Assert = typeof import('vitest').assert & {
htmlEqual(a: string, b: string, description?: string): void;
Expand All @@ -37,6 +36,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
compileOptions?: Partial<CompileOptions>;
props?: Props;
server_props?: Props;
id_prefix?: string;
before_test?: () => void;
after_test?: () => void;
test?: (args: {
Expand Down Expand Up @@ -285,7 +285,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);
Expand Down Expand Up @@ -346,7 +347,10 @@ async function run_test_variant(

if (runes) {
props = proxy({ ...(config.props || {}) });
reset_props_id();

// @ts-expect-error
globalThis.__svelte.uid = 1;

if (manual_hydrate) {
hydrate_fn = () => {
instance = hydrate(mod.default, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
let id = $props.id();
</script>

<p>{id}</p>
Original file line number Diff line number Diff line change
@@ -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,
`
<button>toggle</button>
<h1>c1</h1>
<p>c2</p>
<p>c3</p>
<p>c4</p>
`
);
} else {
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>myPrefix-s1</h1>
<p>myPrefix-s2</p>
<p>myPrefix-s3</p>
<p>myPrefix-s4</p>
`
);
}

let button = target.querySelector('button');
flushSync(() => button?.click());

if (variant === 'dom') {
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>c1</h1>
<p>c2</p>
<p>c3</p>
<p>c4</p>
<p>c5</p>
`
);
} else {
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<h1>myPrefix-s1</h1>
<p>myPrefix-s2</p>
<p>myPrefix-s3</p>
<p>myPrefix-s4</p>
<p>c1</p>
`
);
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script>
import Child from './Child.svelte';
let id = $props.id();
let show = $state(false);
</script>

<button onclick={() => show = !show}>toggle</button>

<h1>{id}</h1>

<Child />
<Child />
<Child />

{#if show}
<Child />
{/if}
3 changes: 2 additions & 1 deletion packages/svelte/tests/server-side-rendering/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { CompileOptions } from '#compiler';
interface SSRTest extends BaseTest {
compileOptions?: Partial<CompileOptions>;
props?: Record<string, any>;
id_prefix?: string;
withoutNormalizeHtml?: boolean;
errors?: string[];
}
Expand All @@ -33,7 +34,7 @@ const { test, run } = suite<SSRTest>(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);
Expand Down
13 changes: 11 additions & 2 deletions packages/svelte/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ declare module 'svelte' {
intro?: boolean;
recover?: boolean;
sync?: boolean;
idPrefix?: string;
$$inline?: boolean;
}

Expand Down Expand Up @@ -2080,11 +2081,19 @@ declare module 'svelte/server' {
...args: {} extends Props
? [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options?: { props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }
options?: {
props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
: [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options: { props: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any> }
options: {
props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
): RenderOutput;
interface RenderOutput {
Expand Down

0 comments on commit b82692a

Please sign in to comment.