diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4851fc4..8b4845b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,25 +1,8 @@ version: 2 updates: -- package-ecosystem: npm - directory: "/" - schedule: - interval: daily - time: "10:00" - open-pull-requests-limit: 10 - ignore: - - dependency-name: husky - versions: - - 5.0.9 - - 5.1.0 - - 5.1.1 - - 5.1.2 - - 5.1.3 - - 5.2.0 - - dependency-name: "@commitlint/config-conventional" - versions: - - 12.0.0 - - 12.0.1 - - dependency-name: "@commitlint/cli" - versions: - - 12.0.0 - - 12.0.1 + - package-ecosystem: npm + directory: '/' + schedule: + interval: daily + time: '10:00' + open-pull-requests-limit: 10 diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index ad6df65..9050f17 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -1,4 +1,4 @@ -name: "Lint PR" +name: 'Lint PR' on: pull_request_target: @@ -12,6 +12,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v4 + - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e218a53..4bb6e56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,23 @@ jobs: if: ${{ !contains(github.head_ref, 'all-contributors') }} name: Node ${{ matrix.node }}, Svelte ${{ matrix.svelte }}, ${{ matrix.test-runner }} runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} strategy: + fail-fast: false matrix: node: ['16', '18', '20'] svelte: ['3', '4'] test-runner: ['vitest:jsdom', 'vitest:happy-dom'] + experimental: [false] + include: + - node: '20' + svelte: 'next' + test-runner: 'vitest:jsdom' + experimental: true + - node: '20' + svelte: 'next' + test-runner: 'vitest:happy-dom' + experimental: true steps: - name: ⬇️ Checkout repo diff --git a/package.json b/package.json index d6a153d..938f563 100644 --- a/package.json +++ b/package.json @@ -64,14 +64,15 @@ "contributors:generate": "all-contributors generate" }, "peerDependencies": { - "svelte": "^3 || ^4" + "svelte": "^3 || ^4 || ^5" }, "dependencies": { "@testing-library/dom": "^9.3.1" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^2.4.2", + "@sveltejs/vite-plugin-svelte": "^3.0.2", "@testing-library/jest-dom": "^6.3.0", + "@testing-library/user-event": "^14.5.2", "@typescript-eslint/eslint-plugin": "6.19.1", "@typescript-eslint/parser": "6.19.1", "@vitest/coverage-v8": "^0.33.0", @@ -93,11 +94,11 @@ "npm-run-all": "^4.1.5", "prettier": "3.2.4", "prettier-plugin-svelte": "3.1.2", - "svelte": "^3 || ^4", + "svelte": "^4.2.10", "svelte-check": "^3.6.3", "svelte-jester": "^3.0.0", "typescript": "^5.3.3", - "vite": "^4.3.9", + "vite": "^5.1.1", "vitest": "^0.33.0" } } diff --git a/src/__tests__/__snapshots__/render.test.js.snap b/src/__tests__/__snapshots__/render.test.js.snap index a31cf2e..b9eb849 100644 --- a/src/__tests__/__snapshots__/render.test.js.snap +++ b/src/__tests__/__snapshots__/render.test.js.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`render > should accept svelte component options 1`] = ` +exports[`render > should accept svelte v4 component options 1`] = `

should accept svelte component options 1`] = ` -
`; + +exports[`render > should accept svelte v5 component options 1`] = ` + + + + +
+

+ Hello World! +

+ +
+ we have context +
+ + + +
+ +`; diff --git a/src/__tests__/cleanup.test.js b/src/__tests__/cleanup.test.js new file mode 100644 index 0000000..789338d --- /dev/null +++ b/src/__tests__/cleanup.test.js @@ -0,0 +1,35 @@ +import { describe, expect, test, vi } from 'vitest' + +import { act, cleanup, render } from '..' +import Mounter from './fixtures/Mounter.svelte' + +const onExecuted = vi.fn() +const onDestroyed = vi.fn() +const renderSubject = () => render(Mounter, { onExecuted, onDestroyed }) + +describe('cleanup', () => { + test('cleanup deletes element', async () => { + renderSubject() + cleanup() + + expect(document.body).toBeEmptyDOMElement() + }) + + test('cleanup unmounts component', async () => { + await act(renderSubject) + cleanup() + + expect(onDestroyed).toHaveBeenCalledOnce() + }) + + test('cleanup handles unexpected errors during mount', () => { + onExecuted.mockImplementation(() => { + throw new Error('oh no!') + }) + + expect(renderSubject).toThrowError() + cleanup() + + expect(document.body).toBeEmptyDOMElement() + }) +}) diff --git a/src/__tests__/fixtures/Mounter.svelte b/src/__tests__/fixtures/Mounter.svelte deleted file mode 100644 index 477bb34..0000000 --- a/src/__tests__/fixtures/Mounter.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - + +{#if show} +
(introDone = true)}> + {#if introDone} +

Done

+ {:else} +

Pending

+ {/if} +
+{/if} diff --git a/src/__tests__/mount.test.js b/src/__tests__/mount.test.js index 830b513..898aa6a 100644 --- a/src/__tests__/mount.test.js +++ b/src/__tests__/mount.test.js @@ -3,31 +3,31 @@ import { describe, expect, test, vi } from 'vitest' import { act, render, screen } from '..' import Mounter from './fixtures/Mounter.svelte' -describe('mount and destroy', () => { - const handleMount = vi.fn() - const handleDestroy = vi.fn() +const onMounted = vi.fn() +const onDestroyed = vi.fn() +const renderSubject = () => render(Mounter, { onMounted, onDestroyed }) +describe('mount and destroy', () => { test('component is mounted', async () => { - await act(() => { - render(Mounter, { onMounted: handleMount, onDestroyed: handleDestroy }) - }) + renderSubject() const content = screen.getByRole('button') - expect(handleMount).toHaveBeenCalledOnce() expect(content).toBeInTheDocument() + await act() + expect(onMounted).toHaveBeenCalledOnce() }) test('component is destroyed', async () => { - const { unmount } = render(Mounter, { - onMounted: handleMount, - onDestroyed: handleDestroy, - }) + const { unmount } = renderSubject() + + await act() + unmount() - await act(() => unmount()) const content = screen.queryByRole('button') - expect(handleDestroy).toHaveBeenCalledOnce() expect(content).not.toBeInTheDocument() + await act() + expect(onDestroyed).toHaveBeenCalledOnce() }) }) diff --git a/src/__tests__/render.test.js b/src/__tests__/render.test.js index 6beb984..262e062 100644 --- a/src/__tests__/render.test.js +++ b/src/__tests__/render.test.js @@ -1,3 +1,4 @@ +import { VERSION as SVELTE_VERSION } from 'svelte/compiler' import { beforeEach, describe, expect, test } from 'vitest' import { act, render as stlRender } from '..' @@ -11,13 +12,13 @@ describe('render', () => { return stlRender(Comp, { target: document.body, props, - ...additional + ...additional, }) } beforeEach(() => { props = { - name: 'World' + name: 'World', } }) @@ -41,7 +42,9 @@ describe('render', () => { }) test('change props with accessors', async () => { - const { component, getByText } = render({ accessors: true }) + const { component, getByText } = render( + SVELTE_VERSION < '5' ? { accessors: true } : {} + ) expect(getByText('Hello World!')).toBeInTheDocument() @@ -59,23 +62,41 @@ describe('render', () => { expect(getByText('Hello World!')).toBeInTheDocument() }) - test('should accept svelte component options', () => { - const target = document.createElement('div') - const div = document.createElement('div') - document.body.appendChild(target) - target.appendChild(div) - const { container } = stlRender(Comp, { - target, - anchor: div, - props: { name: 'World' }, - context: new Map([['name', 'context']]) - }) - expect(container).toMatchSnapshot() - }) + test.runIf(SVELTE_VERSION < '5')( + 'should accept svelte v4 component options', + () => { + const target = document.createElement('div') + const div = document.createElement('div') + document.body.appendChild(target) + target.appendChild(div) + const { container } = stlRender(Comp, { + target, + anchor: div, + props: { name: 'World' }, + context: new Map([['name', 'context']]), + }) + expect(container).toMatchSnapshot() + } + ) + + test.runIf(SVELTE_VERSION >= '5')( + 'should accept svelte v5 component options', + () => { + const target = document.createElement('section') + document.body.appendChild(target) + + const { container } = stlRender(Comp, { + target, + props: { name: 'World' }, + context: new Map([['name', 'context']]), + }) + expect(container).toMatchSnapshot() + } + ) test('should throw error when mixing svelte component options and props', () => { expect(() => { - stlRender(Comp, { anchor: '', name: 'World' }) + stlRender(Comp, { props: {}, name: 'World' }) }).toThrow(/Unknown options were found/) }) @@ -93,10 +114,8 @@ describe('render', () => { test("accept the 'context' option", () => { const { getByText } = stlRender(Comp, { - props: { - name: 'Universe' - }, - context: new Map([['name', 'context']]) + props: { name: 'Universe' }, + context: new Map([['name', 'context']]), }) expect(getByText('we have context')).toBeInTheDocument() diff --git a/src/__tests__/rerender.test.js b/src/__tests__/rerender.test.js index d6cbc21..922ea63 100644 --- a/src/__tests__/rerender.test.js +++ b/src/__tests__/rerender.test.js @@ -1,24 +1,24 @@ /** * @jest-environment jsdom */ -import { expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { writable } from 'svelte/store' -import { render, waitFor } from '..' +import { act, render, waitFor } from '..' import Comp from './fixtures/Rerender.svelte' -const mountCounter = writable(0) - test('mounts new component successfully', async () => { + const onMounted = vi.fn() + const onDestroyed = vi.fn() + const { getByTestId, rerender } = render(Comp, { - props: { name: 'World 1' }, - context: new Map(Object.entries({ mountCounter })), + props: { name: 'World 1', onMounted, onDestroyed }, }) const expectToRender = (content) => waitFor(() => { expect(getByTestId('test')).toHaveTextContent(content) - expect(getByTestId('mount-counter')).toHaveTextContent('1') + expect(onMounted).toHaveBeenCalledOnce() }) await expectToRender('Hello World 1!') @@ -27,12 +27,15 @@ test('mounts new component successfully', async () => { rerender({ props: { name: 'World 2' } }) await expectToRender('Hello World 2!') + expect(onDestroyed).not.toHaveBeenCalled() - expect(console.warn).toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledOnce() console.warn.mockClear() + onDestroyed.mockReset() rerender({ name: 'World 3' }) await expectToRender('Hello World 3!') + expect(onDestroyed).not.toHaveBeenCalled() expect(console.warn).not.toHaveBeenCalled() }) diff --git a/src/__tests__/transition.test.js b/src/__tests__/transition.test.js new file mode 100644 index 0000000..e8191f5 --- /dev/null +++ b/src/__tests__/transition.test.js @@ -0,0 +1,31 @@ +import { userEvent } from '@testing-library/user-event' +import { VERSION as SVELTE_VERSION } from 'svelte/compiler' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { render, screen, waitFor } from '..' +import Transitioner from './fixtures/Transitioner.svelte' + +describe.runIf(SVELTE_VERSION < '5')('transitions', () => { + beforeEach(() => { + if (window.navigator.userAgent.includes('jsdom')) { + const raf = (fn) => setTimeout(() => fn(new Date()), 16) + vi.stubGlobal('requestAnimationFrame', raf) + } + }) + + test('on:introend', async () => { + const user = userEvent.setup() + + render(Transitioner) + const start = screen.getByRole('button') + await user.click(start) + + const pending = screen.getByTestId('intro-pending') + expect(pending).toBeInTheDocument() + + await waitFor(() => { + const done = screen.queryByTestId('intro-done') + expect(done).toBeInTheDocument() + }) + }) +}) diff --git a/src/pure.js b/src/pure.js index 59c62ff..a672b64 100644 --- a/src/pure.js +++ b/src/pure.js @@ -3,19 +3,15 @@ import { getQueriesForElement, prettyDOM, } from '@testing-library/dom' -import { tick } from 'svelte' +import * as Svelte from 'svelte' -const containerCache = new Set() +const IS_SVELTE_5 = typeof Svelte.createRoot === 'function' +const targetCache = new Set() const componentCache = new Set() -const svelteComponentOptions = [ - 'accessors', - 'anchor', - 'props', - 'hydrate', - 'intro', - 'context', -] +const svelteComponentOptions = IS_SVELTE_5 + ? ['target', 'props', 'events', 'context', 'intro', 'recover'] + : ['accessors', 'anchor', 'props', 'hydrate', 'intro', 'context'] const render = ( Component, @@ -24,6 +20,7 @@ const render = ( ) => { container = container || document.body target = target || container.appendChild(document.createElement('div')) + targetCache.add(target) const ComponentConstructor = Component.default || Component @@ -54,17 +51,27 @@ const render = ( return { props: options } } - let component = new ComponentConstructor({ - target, - ...checkProps(options), - }) + const renderComponent = (options) => { + options = { target, ...checkProps(options) } - containerCache.add({ container, target, component }) - componentCache.add(component) + const component = IS_SVELTE_5 + ? Svelte.createRoot(ComponentConstructor, options) + : new ComponentConstructor(options) - component.$$.on_destroy.push(() => { - componentCache.delete(component) - }) + componentCache.add(component) + + // TODO(mcous, 2024-02-11): remove this behavior in the next major version + // It is unnecessary has no path to implementation in Svelte v5 + if (!IS_SVELTE_5) { + component.$$.on_destroy.push(() => { + componentCache.delete(component) + }) + } + + return component + } + + let component = renderComponent(options) return { container, @@ -78,48 +85,53 @@ const render = ( props = props.props } component.$set(props) - await tick() + await Svelte.tick() }, unmount: () => { - if (componentCache.has(component)) component.$destroy() + cleanupComponent(component) }, ...getQueriesForElement(container, queries), } } -const cleanupAtContainer = (cached) => { - const { target, component } = cached +const cleanupComponent = (component) => { + const inCache = componentCache.delete(component) - if (componentCache.has(component)) component.$destroy() + if (inCache) { + component.$destroy() + } +} - if (target.parentNode === document.body) { +const cleanupTarget = (target) => { + const inCache = targetCache.delete(target) + + if (inCache && target.parentNode === document.body) { document.body.removeChild(target) } - - containerCache.delete(cached) } const cleanup = () => { - Array.from(containerCache.keys()).forEach(cleanupAtContainer) + componentCache.forEach(cleanupComponent) + targetCache.forEach(cleanupTarget) } const act = async (fn) => { if (fn) { await fn() } - return tick() + return Svelte.tick() } const fireEvent = async (...args) => { const event = dtlFireEvent(...args) - await tick() + await Svelte.tick() return event } Object.keys(dtlFireEvent).forEach((key) => { fireEvent[key] = async (...args) => { const event = dtlFireEvent[key](...args) - await tick() + await Svelte.tick() return event } }) diff --git a/vite.config.js b/vite.config.js index 4baf76f..0ad04cc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,7 +3,7 @@ import { defineConfig } from 'vite' // https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ - plugins: [svelte()], + plugins: [svelte({ hot: false })], resolve: { // Ensure `browser` exports are used in tests // Vitest prefers modules' `node` export by default