feat: attachments `fromAction` utility (#15933)

* feat: attachments `fromAction` utility

* fix: typing of the utility

Co-authored-by: Aidan Bleser <117548273+ieedan@users.noreply.github.com>

* simplify implementation - create action first, then only create update/destroy effects if necessary

* add since

* regenerate

* remove FromAction interface

* overload

* fix: use typedef instead of exported interface

* get rid of the arg0, arg1 stuff

* oops

* god i hate overloads

* defer to the reference documentation

* damn ur weird typescript

* gah

---------

Co-authored-by: Aidan Bleser <117548273+ieedan@users.noreply.github.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/15972/head
Paolo Ricciuti 4 months ago committed by GitHub
parent 22a0211cbb
commit 7183886a73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: attachments `fromAction` utility

@ -160,3 +160,7 @@ function foo(+++getBar+++) {
## Creating attachments programmatically ## Creating attachments programmatically
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey). To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
## Converting actions to attachments
If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](svelte-attachments#fromAction), allowing you to (for example) use them with components.

@ -1,4 +1,9 @@
/** @import { Action, ActionReturn } from '../action/public' */
/** @import { Attachment } from './public' */
import { noop, render_effect } from 'svelte/internal/client';
import { ATTACHMENT_KEY } from '../constants.js'; import { ATTACHMENT_KEY } from '../constants.js';
import { untrack } from 'svelte';
import { teardown } from '../internal/client/reactivity/effects.js';
/** /**
* Creates an object key that will be recognised as an attachment when the object is spread onto an element, * Creates an object key that will be recognised as an attachment when the object is spread onto an element,
@ -25,3 +30,84 @@ import { ATTACHMENT_KEY } from '../constants.js';
export function createAttachmentKey() { export function createAttachmentKey() {
return Symbol(ATTACHMENT_KEY); return Symbol(ATTACHMENT_KEY);
} }
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* @template {EventTarget} E
* @template {unknown} T
* @overload
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
* @param {() => T} fn A function that returns the argument for the action
* @returns {Attachment<E>}
*/
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* @template {EventTarget} E
* @overload
* @param {Action<E, void> | ((element: E) => void | ActionReturn<void>)} action The action function
* @returns {Attachment<E>}
*/
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
*
* @template {EventTarget} E
* @template {unknown} T
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
* @param {() => T} fn A function that returns the argument for the action
* @returns {Attachment<E>}
* @since 5.32
*/
export function fromAction(action, fn = /** @type {() => T} */ (noop)) {
return (element) => {
const { update, destroy } = untrack(() => action(element, fn()) ?? {});
if (update) {
var ran = false;
render_effect(() => {
const arg = fn();
if (ran) update(arg);
});
ran = true;
}
if (destroy) {
teardown(destroy);
}
};
}

@ -0,0 +1,116 @@
import { ok, test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target, logs }) {
const [btn, btn2, btn3] = target.querySelectorAll('button');
// both logs on creation it will not log on change
assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment']);
// clicking the first button logs the right value
flushSync(() => {
btn?.click();
});
assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment', 0]);
// clicking the second button logs the right value
flushSync(() => {
btn2?.click();
});
assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment', 0, 0]);
// updating the arguments logs the update function for both
flushSync(() => {
btn3?.click();
});
assert.deepEqual(logs, [
'create',
0,
'action',
'create',
0,
'attachment',
0,
0,
'update',
1,
'action',
'update',
1,
'attachment'
]);
// clicking the first button again shows the right value
flushSync(() => {
btn?.click();
});
assert.deepEqual(logs, [
'create',
0,
'action',
'create',
0,
'attachment',
0,
0,
'update',
1,
'action',
'update',
1,
'attachment',
1
]);
// clicking the second button again shows the right value
flushSync(() => {
btn2?.click();
});
assert.deepEqual(logs, [
'create',
0,
'action',
'create',
0,
'attachment',
0,
0,
'update',
1,
'action',
'update',
1,
'attachment',
1,
1
]);
// unmounting logs the destroy function for both
flushSync(() => {
btn3?.click();
});
assert.deepEqual(logs, [
'create',
0,
'action',
'create',
0,
'attachment',
0,
0,
'update',
1,
'action',
'update',
1,
'attachment',
1,
1,
'destroy',
'action',
'destroy',
'attachment'
]);
}
});

@ -0,0 +1,37 @@
<script>
import { fromAction } from 'svelte/attachments';
let { count = 0 } = $props();
function test(node, thing) {
const kind = node.dataset.kind;
console.log('create', thing, kind);
let t = thing;
const controller = new AbortController();
node.addEventListener(
'click',
() => {
console.log(t);
},
{
signal: controller.signal
}
);
return {
update(new_thing) {
console.log('update', new_thing, kind);
t = new_thing;
},
destroy() {
console.log('destroy', kind);
controller.abort();
}
};
}
</script>
{#if count < 2}
<button data-kind="action" use:test={count}></button>
<button data-kind="attachment" {@attach fromAction(test, ()=>count)}></button>
{/if}
<button onclick={()=> count++}></button>

@ -658,6 +658,107 @@ declare module 'svelte/attachments' {
* @since 5.29 * @since 5.29
*/ */
export function createAttachmentKey(): symbol; export function createAttachmentKey(): symbol;
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* */
export function fromAction<E extends EventTarget, T extends unknown>(action: Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>), fn: () => T): Attachment<E>;
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* */
export function fromAction<E extends EventTarget>(action: Action<E, void> | ((element: E) => void | ActionReturn<void>)): Attachment<E>;
/**
* Actions can return an object containing the two properties defined in this interface. Both are optional.
* - update: An action can have a parameter. This method will be called whenever that parameter changes,
* immediately after Svelte has applied updates to the markup. `ActionReturn` and `ActionReturn<undefined>` both
* mean that the action accepts no parameters.
* - destroy: Method that is called after the element is unmounted
*
* Additionally, you can specify which additional attributes and events the action enables on the applied element.
* This applies to TypeScript typings only and has no effect at runtime.
*
* Example usage:
* ```ts
* interface Attributes {
* newprop?: string;
* 'on:event': (e: CustomEvent<boolean>) => void;
* }
*
* export function myAction(node: HTMLElement, parameter: Parameter): ActionReturn<Parameter, Attributes> {
* // ...
* return {
* update: (updatedParameter) => {...},
* destroy: () => {...}
* };
* }
* ```
*/
interface ActionReturn<
Parameter = undefined,
Attributes extends Record<string, any> = Record<never, any>
> {
update?: (parameter: Parameter) => void;
destroy?: () => void;
/**
* ### DO NOT USE THIS
* This exists solely for type-checking and has no effect at runtime.
* Set this through the `Attributes` generic instead.
*/
$$_attributes?: Attributes;
}
/**
* Actions are functions that are called when an element is created.
* You can use this interface to type such actions.
* The following example defines an action that only works on `<div>` elements
* and optionally accepts a parameter which it has a default value for:
* ```ts
* export const myAction: Action<HTMLDivElement, { someProperty: boolean } | undefined> = (node, param = { someProperty: true }) => {
* // ...
* }
* ```
* `Action<HTMLDivElement>` and `Action<HTMLDivElement, undefined>` both signal that the action accepts no parameters.
*
* You can return an object with methods `update` and `destroy` from the function and type which additional attributes and events it has.
* See interface `ActionReturn` for more details.
*/
interface Action<
Element = HTMLElement,
Parameter = undefined,
Attributes extends Record<string, any> = Record<never, any>
> {
<Node extends Element>(
...args: undefined extends Parameter
? [node: Node, parameter?: Parameter]
: [node: Node, parameter: Parameter]
): void | ActionReturn<Parameter, Attributes>;
}
// Implementation notes:
// - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode
export {}; export {};
} }

Loading…
Cancel
Save