Skip to content

feat: add createRawSnippet API #12409

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-zoos-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: add createRawSnippet API
2 changes: 2 additions & 0 deletions packages/svelte/src/index-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,5 @@ export {
tick,
untrack
} from './internal/client/runtime.js';

export { createRawSnippet } from './internal/client/dom/blocks/snippet.js';
2 changes: 2 additions & 0 deletions packages/svelte/src/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ export function unmount() {
export async function tick() {}

export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';

export { createRawSnippet } from './internal/server/index.js';
29 changes: 28 additions & 1 deletion packages/svelte/src/internal/client/dom/blocks/snippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
dev_current_component_function,
set_dev_current_component_function
} from '../../runtime.js';
import { hydrate_node, hydrating } from '../hydration.js';
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
import { assign_nodes } from '../template.js';

/**
* @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn
Expand Down Expand Up @@ -60,3 +61,29 @@ export function wrap_snippet(component, fn) {
}
});
}

/**
* Create a snippet imperatively using mount, hyrdate and render functions.
* @param {{
* mount: (...params: any[]) => Element,
* hydrate?: (element: Element, ...params: any[]) => void,
* render: (...params: any[]) => string
* }} options
*/
export function createRawSnippet({ mount, hydrate }) {
var snippet_fn = (/** @type {TemplateNode} */ anchor, /** @type {any[]} */ ...params) => {
var element;
if (hydrating) {
element = hydrate_node;
hydrate_next();
if (hydrate !== undefined) hydrate(/** @type {Element} */ (element), ...params);
} else {
element = mount(...params);
anchor.before(element);
}
assign_nodes(element, element);
};
add_snippet_symbol(snippet_fn);

return snippet_fn;
}
18 changes: 17 additions & 1 deletion packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { escape_html } from '../../escaping.js';
import { DEV } from 'esm-env';
import { current_component, pop, push } from './context.js';
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { validate_store } from '../shared/validate.js';
import { add_snippet_symbol, validate_store } from '../shared/validate.js';

// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter
Expand Down Expand Up @@ -155,6 +155,22 @@ export function head(payload, fn) {
head_payload.out += BLOCK_CLOSE;
}

/**
* Create a snippet imperatively using mount, hyrdate and render functions.
* @param {{
* mount: (...params: any[]) => Element,
* hydrate?: (element: Element, ...params: any[]) => void,
* render: (...params: any[]) => string
* }} options
*/
export function createRawSnippet({ render }) {
const snippet_fn = (/** @type {Payload} */ payload, /** @type {any[]} */ ...args) => {
payload.out += render(...args);
Copy link
Member

@dummdidumm dummdidumm Jul 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Someone could render a component inside the snippet and then want to add something to the head. Should snippet in SSR mode therefore be required to return an object with html and head (for convenience returning a string could be a shorthand for returning an object with html)?
If yes, how would the equivalent on the client look like?

Copy link
Contributor Author

@trueadm trueadm Jul 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I updated it to be body and head from the function instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this. You can't have <svelte:head> inside a {#snippet ...}, which is the equivalent, surely?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rich-Harris You can have <SomeComponent /> inside the snippet which has a <svelte:head> though?

Copy link
Contributor Author

@trueadm trueadm Jul 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, scrap that, Astro and others would likely just use mount or hydrate, right?

https://github.com/withastro/astro/blob/main/packages/integrations/svelte/client-v5.js#L24

I reverted my changes back to just returning a string instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They do, but on the server the use render: https://github.com/withastro/astro/blob/main/packages/integrations/svelte/server-v5.js#L32

Also, if you're using mount and hydrate, you get support for adding stuff to the head. So why wouldn't you be able to manually do that here aswell?

	const snippet = createRawSnippet({
		mount(text) {
			const title = document.createElement('title');

			$effect(() => {
				title.textContent = text();
			});

			return document.createComment('');
		},
		hydrate(element, count) {
			$effect(() => {
				document.head.querySelector('title[data-x]').textContent = text();
			});
		},
		render(count) {
			return { html: '<!---->', head: '<title data-x>text</title>' }
		}
	});

(which then begs the question: How do you cleanup head content when the snippet is destroyed?)

Astro is currently not making use of properly having support for head content, but in theory they could add it I think? @bluwy is this in general possible in Astro for a framework to contribute to contents in the <head> tag?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, if you're using mount and hydrate, you get support for adding stuff to the head.

I mean I removed it because it complicated this and Astro wasn't making use of it, plus after implementing it I ran into countless hydration issues, maybe related to #12399.

I'm also a bit unsure how someone might hydrate the head in their hydrate function being able to control the internal node logic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you cleanup head content when the snippet is destroyed?

With an effect, presumably?

I'm in two minds about this whole thing. It does feel like a limitation (regardless of whether Astro specifically would be able to take advantage of it), but getting it to work well would add a ton of complexity when you factor in hydration and stuff. Like, a simple thing like this...

<p>before</p>
{#if condition}
  <div class="a"><A /></div>
{:else}
  <div class="b"><B /></div>
{/if}
<p>after</p>

...would presumably have to become this, if it's possible that A or B contains head content (directly or indirectly):

render: (condition) => {
  let head = '';
  let body = '<p>before</p>';

  if (condition) {
    const rendered = render(A);
    body += '<div class="a">${rendered.body}</div>`
    head += rendered.head;
  } else {
    const rendered = render(B);
    body += '<div class="b">${rendered.body}</div>`
    head += rendered.head;
  }

  body += '<p>after</p>';
  
  return { head, body };
}

As opposed to this, if you just allow the constraint that programmatic snippets can't create head content:

render: (condition) => {
  return (
    '<p>before</p>' +
    condition ? `<div class="a">${render(A).body}</div>` : `<div class="b">${render(B).body}</div>` +
    '<p>after</p>
  );
}

So the increased complexity disadvantages users more than us. I suppose the alternative is that just expose the payload object...

render: (payload, condition) => {
  payload.out += '<p>before</p>';
  if (condition) {
    payload.out += '<div class="a">';
    A(payload);
    payload.out += '</div>';
  } else {
    payload.out += '<div class="b">';
    B(payload);
    payload.out += '</div>';
  }
  payload.out += '<p>after</p>';
}

...but that hardly feels like an improvement. So I think I land on where we are now — keeping it simple.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Also, there's already a limitation with this API, insofar as you have to have a top-level element. So it's not like we can advertise it as a full-blown alternative to {#snippet})

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bluwy is this in general possible in Astro for a framework to contribute to contents in the <head> tag?

At the moment I don't think it's possible, especially with regards to streaming, it's not easy to aggregate them beforehand unless Astro do something to detect if components exposes the head, which is tricky. I think it's ok to punt this off for now though.

};
add_snippet_symbol(snippet_fn);
return snippet_fn;
}

/**
* @template V
* @param {string} name
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
compileOptions: {
dev: true // Render in dev mode to check that the validation error is not thrown
},
html: `<div><div>0</div></div><button>+</button>`,

test({ assert, target }) {
const [b1] = target.querySelectorAll('button');

b1?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<div><div>1</div></div><button>+</button>`);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script>
import { createRawSnippet } from 'svelte';

let count = $state(0);

const snippet = createRawSnippet({
mount(count) {
const div = document.createElement('div');

$effect(() => {
div.textContent = count();
});

return div;
},
hydrate(element, count) {

$effect(() => {
element.textContent = count();
});

},
render(count) {
return `<div>${count}</div>`;
}
});
</script>

<div>
{@render snippet(count)}
</div>
<button onclick={() => count++}>+</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { test } from '../../test';

export default test({
compileOptions: {
dev: true // Render in dev mode to check that the validation error is not thrown
},
html: `<p>hello world</p>`
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script>
import { createRawSnippet } from 'svelte';

const hello = createRawSnippet({
mount() {
const p = document.createElement('p')
p.textContent = 'hello world';
return p;
},
render() {
return '<p>hello world</p>';
}
});
</script>

{@render hello()}
15 changes: 12 additions & 3 deletions packages/svelte/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,12 +365,20 @@ declare module 'svelte' {
export function flushSync(fn?: (() => void) | undefined): void;
/** Anything except a function */
type NotFunction<T> = T extends Function ? never : T;
/**
* Create a snippet imperatively using mount, hyrdate and render functions.
* */
export function createRawSnippet({ mount, hydrate }: {
mount: (...params: any[]) => Element;
hydrate?: (element: Element, ...params: any[]) => void;
render: (...params: any[]) => string;
}): (anchor: TemplateNode, ...params: any[]) => void;
/**
* Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component.
* Transitions will play during the initial render unless the `intro` option is set to `false`.
*
* */
export function mount<Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? {
function mount_1<Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? {
target: Document | Element | ShadowRoot;
anchor?: Node;
props?: Props;
Expand All @@ -389,7 +397,7 @@ declare module 'svelte' {
* Hydrates a component on the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component
*
* */
export function hydrate<Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? {
function hydrate_1<Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? {
target: Document | Element | ShadowRoot;
props?: Props;
events?: Record<string, (e: any) => any>;
Expand Down Expand Up @@ -450,8 +458,9 @@ declare module 'svelte' {
* https://svelte.dev/docs/svelte#getallcontexts
* */
export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T;
type TemplateNode = Text | Element | Comment;

export {};
export { hydrate_1 as hydrate, mount_1 as mount };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to this PR, but weird why dts-buddy does this

}

declare module 'svelte/action' {
Expand Down
Loading