Skip to content
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

[Svelte] Svelte 5 support #2288

Draft
wants to merge 3 commits into
base: 2.x
Choose a base branch
from
Draft
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
7 changes: 4 additions & 3 deletions src/Svelte/assets/dist/render_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export default class extends Controller<Element & {
props: ObjectConstructor;
intro: BooleanConstructor;
};
connect(): void;
disconnect(): void;
_destroyIfExists(): void;
connect(): Promise<void>;
disconnect(): Promise<void>;
_destroyIfExists(): Promise<void>;
private dispatchEvent;
private mountSvelteComponent;
}
27 changes: 20 additions & 7 deletions src/Svelte/assets/dist/render_controller.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Controller } from '@hotwired/stimulus';

class default_1 extends Controller {
connect() {
async connect() {
this.element.innerHTML = '';
this.props = this.propsValue ?? undefined;
this.intro = this.introValue ?? undefined;
this.dispatchEvent('connect');
const Component = window.resolveSvelteComponent(this.componentValue);
this._destroyIfExists();
this.app = new Component({
await this._destroyIfExists();
this.app = await this.mountSvelteComponent(Component, {
target: this.element,
props: this.props,
intro: this.intro,
Expand All @@ -18,13 +18,19 @@ class default_1 extends Controller {
component: Component,
});
}
disconnect() {
this._destroyIfExists();
async disconnect() {
await this._destroyIfExists();
this.dispatchEvent('unmount');
}
_destroyIfExists() {
async _destroyIfExists() {
if (this.element.root !== undefined) {
this.element.root.$destroy();
const { unmount } = await import('svelte');
if (unmount) {
unmount(this.element.root);
}
else {
this.element.root.$destroy();
}
delete this.element.root;
}
}
Expand All @@ -37,6 +43,13 @@ class default_1 extends Controller {
};
this.dispatch(name, { detail, prefix: 'svelte' });
}
async mountSvelteComponent(Component, options) {
const { mount } = await import('svelte');
Copy link
Member

Choose a reason for hiding this comment

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

This will not work with AssetMapper right ? In which cas we need to find a solution because this would be a giant BC break :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That was my first thought but it worked when I tried it in ux.symfony.com !
I also tried to have import { mount } from 'svelte' at the top of the file but it doesn't work with AssetMapper with Svelte 4, an error was thrown because mount only exists in v5
I can't explain why but with a dynamic import there is no error, mount is just undefined

Copy link
Member

Choose a reason for hiding this comment

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

I'll test this next week, thanks for the explanations :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice! FYI with AssetMapper it works with Svelte 4, but with Svelte 5 I'm stuck with the issue described here
I made a REPL with only html/js that reproduce the error

if (mount) {
return mount(Component, options);
}
return new Component(options);
}
}
default_1.values = {
component: String,
Expand Down
6 changes: 3 additions & 3 deletions src/Svelte/assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
},
"peerDependencies": {
"@hotwired/stimulus": "^3.0.0",
"svelte": "^3.0 || ^4.0"
"svelte": "^3.0 || ^4.0 || ^5.0"
},
"devDependencies": {
"@hotwired/stimulus": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^2.4.6",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/webpack-env": "^1.16",
"svelte": "^3.0 || ^4.0"
"svelte": "^3.0 || ^4.0 || ^5.0"
}
}
39 changes: 30 additions & 9 deletions src/Svelte/assets/src/render_controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller } from '@hotwired/stimulus';
import type { SvelteComponent } from 'svelte';
import type { SvelteComponent, ComponentConstructorOptions, ComponentType } from 'svelte';

export default class extends Controller<Element & { root?: SvelteComponent }> {
private app: SvelteComponent;
Expand All @@ -17,7 +17,7 @@ export default class extends Controller<Element & { root?: SvelteComponent }> {
intro: Boolean,
};

connect() {
async connect() {
this.element.innerHTML = '';

this.props = this.propsValue ?? undefined;
Expand All @@ -27,10 +27,9 @@ export default class extends Controller<Element & { root?: SvelteComponent }> {

const Component = window.resolveSvelteComponent(this.componentValue);

this._destroyIfExists();
await this._destroyIfExists();

// @see https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component
this.app = new Component({
this.app = await this.mountSvelteComponent(Component, {
target: this.element,
props: this.props,
intro: this.intro,
Expand All @@ -43,14 +42,21 @@ export default class extends Controller<Element & { root?: SvelteComponent }> {
});
}

disconnect() {
this._destroyIfExists();
async disconnect() {
await this._destroyIfExists();
this.dispatchEvent('unmount');
}

_destroyIfExists() {
async _destroyIfExists() {
if (this.element.root !== undefined) {
this.element.root.$destroy();
// @ts-ignore
const { unmount } = await import('svelte');
if (unmount) {
// `unmount` is only available in Svelte >= 5
unmount(this.element.root);
} else {
this.element.root.$destroy();
}
delete this.element.root;
}
}
Expand All @@ -64,4 +70,19 @@ export default class extends Controller<Element & { root?: SvelteComponent }> {
};
this.dispatch(name, { detail, prefix: 'svelte' });
}

// @see https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component
private async mountSvelteComponent(
Component: ComponentType,
options: ComponentConstructorOptions
): Promise<SvelteComponent> {
// @ts-ignore
const { mount } = await import('svelte');
if (mount) {
// `mount` is only available in Svelte >= 5
return mount(Component, options);
}

return new Component(options);
}
}
10 changes: 5 additions & 5 deletions src/Svelte/assets/test/fixtures/MyComponent.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script>
<script>
import { fade } from 'svelte/transition';
export let name = 'without props';
</script>
<div transition:fade|global={{ duration: 100 }}>
<div>Hello {name}</div>
</script>

<div transition:fade|global={{ duration: 100 }}>
<div>Hello {name}</div>
</div>
7 changes: 7 additions & 0 deletions src/Svelte/assets/test/fixtures/MyComponentSvelte5.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
let { name = 'without props' } = $props();
</script>

<div>
<div>Hello {name}</div>
</div>
4 changes: 4 additions & 0 deletions src/Svelte/assets/test/register_controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@

import { registerSvelteControllerComponents } from '../src/register_controller';
import MyComponent from './fixtures/MyComponent.svelte';
import MyComponentSvelte5 from './fixtures/MyComponentSvelte5.svelte';
import RequireContext = __WebpackModuleApi.RequireContext;

const createFakeFixturesContext = (): RequireContext => {
const files: any = {
'./MyComponent.svelte': { default: MyComponent },
'./MyComponentSvelte5.svelte': { default: MyComponentSvelte5 },
};

const context = (id: string): any => files[id];
Expand All @@ -32,5 +34,7 @@ describe('registerSvelteControllerComponents', () => {
expect(resolveComponent).not.toBeUndefined();
expect(resolveComponent('MyComponent')).toBe(MyComponent);
expect(resolveComponent('MyComponent')).not.toBeUndefined();
expect(resolveComponent('MyComponentSvelte5')).toBe(MyComponentSvelte5);
expect(resolveComponent('MyComponentSvelte5')).not.toBeUndefined();
});
});
20 changes: 19 additions & 1 deletion src/Svelte/assets/test/render_controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getByTestId, waitFor } from '@testing-library/dom';
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
import SvelteController from '../src/render_controller';
import MyComponent from './fixtures/MyComponent.svelte';
import { VERSION as SVELTE_VERSION } from 'svelte/compiler';

// Controller used to check the actual controller was properly booted
class CheckController extends Controller {
Expand Down Expand Up @@ -83,7 +84,24 @@ describe('SvelteController', () => {
await waitFor(() => expect(component.innerHTML).toContain('<div><div>Hello without props</div></div>'));
});

it('connect with props and intro', async () => {
it('unmount correctly', async () => {
const container = mountDOM(`
<div data-testid="component" id="container"
data-controller="check svelte"
data-svelte-component-value="SvelteComponent" />
`);

const component = getByTestId(container, 'component');

application = startStimulus();

await waitFor(() => expect(component.innerHTML).toContain('<div><div>Hello without props</div></div>'));
component.remove();
await waitFor(() => expect(component).not.toBeInTheDocument());
});

// Disabled for Svelte 5 : https://github.com/sveltejs/svelte/issues/11280
it.skipIf(SVELTE_VERSION >= '5')('connect with props and intro', async () => {
const container = mountDOM(`
<div data-testid="component" id="container"
data-controller="check svelte"
Expand Down
64 changes: 64 additions & 0 deletions src/Svelte/assets/test/render_svelte_5_controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { Application } from '@hotwired/stimulus';
import { getByTestId, waitFor } from '@testing-library/dom';
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
import SvelteController from '../src/render_controller';
import MyComponentSvelte5 from './fixtures/MyComponentSvelte5.svelte';
import { VERSION as SVELTE_VERSION } from 'svelte/compiler';

const startStimulus = () => {
const application = Application.start();
application.register('svelte', SvelteController);

return application;
};

(window as any).resolveSvelteComponent = () => {
return MyComponentSvelte5;
};

describe.skipIf(SVELTE_VERSION < '5')('Svelte5Controller', () => {
let application: Application;

afterEach(() => {
clearDOM();
application.stop();
});

it('connect with props', async () => {
const container = mountDOM(`
<div data-testid="component"
data-controller="check svelte 5"
data-svelte-component-value="Svelte5Component"
data-svelte-props-value="{&quot;name&quot;: &quot;Svelte 5 !&quot;}" />
`);

const component = getByTestId(container, 'component');

application = startStimulus();

await waitFor(() => expect(component.innerHTML).toContain('<div><div>Hello Svelte 5 !</div></div>'));
});

it('connect without props', async () => {
const container = mountDOM(`
<div data-testid="component" id="container"
data-controller="check svelte 5"
data-svelte-component-value="Svelte5Component" />
`);

const component = getByTestId(container, 'component');

application = startStimulus();

await waitFor(() => expect(component.innerHTML).toContain('<div><div>Hello without props</div></div>'));
});
});
15 changes: 9 additions & 6 deletions src/Svelte/assets/vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import { defineConfig, mergeConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import configShared from '../../../vitest.config.mjs'

export default mergeConfig(
configShared,
defineConfig({
plugins: [svelte()],
})
);
export default defineConfig(configEnv => mergeConfig(
configShared,
defineConfig({
plugins: [svelte()],
resolve: {
conditions: configEnv.mode === 'test' ? ['browser'] : [],
},
})
))
4 changes: 2 additions & 2 deletions src/Svelte/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Svelte is a JavaScript framework for building user interfaces.
Symfony UX Svelte provides tools to render Svelte components from Twig,
handling rendering and data transfers.

Symfony UX Svelte supports Svelte 3 and Svelte 4.
Symfony UX Svelte supports Svelte 3, 4 and 5.

Installation
------------
Expand Down Expand Up @@ -51,7 +51,7 @@ Next, install a package to help Svelte:
That's it! Any files inside ``assets/svelte/controllers/`` can now be rendered as
Svelte components.

If you are using Svelte 4, you will have to add ``browser``, ``import`` and ``svelte``
If you are using Svelte 4 or 5, you will have to add ``browser``, ``import`` and ``svelte``
to the ``conditionNames`` array. This is necessary as per `the Svelte 4 migration guide`_
for bundlers such as webpack, to ensure that lifecycle callbacks are internally invoked.

Expand Down
Loading