diff --git a/.changeset/selection-solid2-migration.md b/.changeset/selection-solid2-migration.md new file mode 100644 index 000000000..d3e4c1237 --- /dev/null +++ b/.changeset/selection-solid2-migration.md @@ -0,0 +1,18 @@ +--- +"@solid-primitives/selection": major +--- + +Migrate to Solid.js v2.0 (beta.10) + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. + +### `@solid-primitives/selection` + +- `isServer` is now imported from `@solidjs/web` (was `solid-js/web`) +- `createEffect` for applying selection converted to the split compute/apply pattern required by Solid 2.0 +- Event listeners are now registered directly with `onCleanup` rather than inside a `createEffect` with no reactive dependencies +- Internal signals now use `{ ownedWrite: true }` (via `INTERNAL_OPTIONS`) to allow `setSelection` to be called from within reactive scopes +- Added `test/server.test.ts` verifying SSR no-op behaviour for `createSelection` +- No changes to the public `createSelection` API or `HTMLSelection` type diff --git a/packages/selection/README.md b/packages/selection/README.md index 4ed73db82..c8be77d06 100644 --- a/packages/selection/README.md +++ b/packages/selection/README.md @@ -18,6 +18,8 @@ npm install @solid-primitives/selection yarn add @solid-primitives/selection ``` +**Requires**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` + ## Usage The format of the getter output and setter input is `HTMLSelection`, consisting of a tuple of the node in which the selection happens and a start and end offset within the text content. The offsets count from zero, so `1` would be the second character. diff --git a/packages/selection/package.json b/packages/selection/package.json index f3f00524e..faf981fd8 100644 --- a/packages/selection/package.json +++ b/packages/selection/package.json @@ -39,7 +39,7 @@ "scripts": { "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", - "vitest": "vitest -c ../../configs/vitest.config.ts", + "vitest": "vitest -c vitest.config.ts", "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, @@ -47,11 +47,18 @@ "solid", "primitives" ], + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.10", + "solid-js": "^2.0.0-beta.10" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@babel/core": "^7.27.0", + "@solidjs/web": "2.0.0-beta.10", + "babel-preset-solid": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" } } diff --git a/packages/selection/src/index.ts b/packages/selection/src/index.ts index 0a8b83435..458335928 100644 --- a/packages/selection/src/index.ts +++ b/packages/selection/src/index.ts @@ -1,5 +1,6 @@ import { type Accessor, createEffect, createSignal, onCleanup, type Setter } from "solid-js"; -import { isServer } from "solid-js/web"; +import { isServer } from "@solidjs/web"; +import { INTERNAL_OPTIONS } from "@solid-primitives/utils"; export type HTMLSelection = [node: HTMLElement | null, start: number, end: number]; @@ -44,8 +45,8 @@ export const createSelection = (): [Accessor, Setter (typeof sel === "function" ? (sel as any)([null, NaN, NaN]) : sel), ]; } - const [getSelection, setSelection] = createSignal([null, NaN, NaN]); - const [selected, setSelected] = createSignal([null, NaN, NaN]); + const [getSelection, setSelection] = createSignal([null, NaN, NaN], INTERNAL_OPTIONS); + const [selected, setSelected] = createSignal([null, NaN, NaN], INTERNAL_OPTIONS); const selectionHandler = () => { const active = document.activeElement; if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) { @@ -68,36 +69,36 @@ export const createSelection = (): [Accessor, Setter { - document.addEventListener("selectionchange", selectionHandler); - document.addEventListener("click", selectionHandler); - document.addEventListener("keyup", selectionHandler); - onCleanup(() => { - document.removeEventListener("selectionchange", selectionHandler); - document.removeEventListener("click", selectionHandler); - document.removeEventListener("keyup", selectionHandler); - }); + document.addEventListener("selectionchange", selectionHandler); + document.addEventListener("click", selectionHandler); + document.addEventListener("keyup", selectionHandler); + onCleanup(() => { + document.removeEventListener("selectionchange", selectionHandler); + document.removeEventListener("click", selectionHandler); + document.removeEventListener("keyup", selectionHandler); }); - createEffect(() => { - const [node, start, end] = selected(); - const selection = window.getSelection(); - if (node === null) { - selection?.rangeCount && selection.removeAllRanges(); - } else if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { - document.activeElement !== node && node.focus(); - node.setSelectionRange(start, end); - } else { - selection?.removeAllRanges(); - const range = document.createRange(); - const texts = getTextNodes(node); - const [startNode, startPos] = getRangeArgs(start, texts); - const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, texts); - if (startNode && endNode && startPos !== -1 && endPos !== -1) { - range.setStart(startNode, startPos); - range.setEnd(endNode, endPos); - selection?.addRange(range); + createEffect( + () => selected(), + ([node, start, end]) => { + const selection = window.getSelection(); + if (node === null) { + selection?.rangeCount && selection.removeAllRanges(); + } else if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { + document.activeElement !== node && node.focus(); + node.setSelectionRange(start, end); + } else { + selection?.removeAllRanges(); + const range = document.createRange(); + const texts = getTextNodes(node); + const [startNode, startPos] = getRangeArgs(start, texts); + const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, texts); + if (startNode && endNode && startPos !== -1 && endPos !== -1) { + range.setStart(startNode, startPos); + range.setEnd(endNode, endPos); + selection?.addRange(range); + } } - } - }); + }, + ); return [getSelection, setSelected]; }; diff --git a/packages/selection/test/index.test.tsx b/packages/selection/test/index.test.tsx index 0d6098f0c..90dd6d1ad 100644 --- a/packages/selection/test/index.test.tsx +++ b/packages/selection/test/index.test.tsx @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { render } from "solid-js/web"; +import { render } from "@solidjs/web"; import { createSelection } from "../src/index.js"; -import { createEffect, createRoot, type JSX } from "solid-js"; +import { createRoot, flush, type JSX } from "solid-js"; describe("createSelection", () => { const renderTest = (component: () => JSX.Element) => { @@ -19,14 +19,8 @@ describe("createSelection", () => { }; }; - const dispatchKeyupEvent = (node: HTMLElement | Document) => - new Promise(resolve => { - node.addEventListener("keyup", () => resolve(), { once: true }); - node.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false })); - }); - - it("reads selection from input", () => - createRoot(async dispose => { + it("reads selection from input", () => { + createRoot(dispose => { const [selection] = createSelection(); expect(selection()).toEqual([null, NaN, NaN]); const { container, unmount } = renderTest(() => ); @@ -34,33 +28,31 @@ describe("createSelection", () => { expect(input).toBeInstanceOf(HTMLInputElement); input.focus(); input.setSelectionRange(1, 3); - // only wrapped in createEffect, we will subscribe to changes - await createEffect(async () => { - await dispatchKeyupEvent(input); - expect(selection()).toEqual([input, 1, 3]); - unmount(); - dispose(); - }); - })); + input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false })); + flush(); + expect(selection()).toEqual([input, 1, 3]); + unmount(); + dispose(); + }); + }); - it("writes selection to an input", () => - createRoot(async dispose => { - const [_selection, setSelection] = createSelection(); + it("writes selection to an input", () => { + createRoot(dispose => { + const [, setSelection] = createSelection(); const { container, unmount } = renderTest(() => ); const input = container.querySelector("input") as HTMLInputElement; expect(input).toBeInstanceOf(HTMLInputElement); setSelection([input, 2, 5]); - await createEffect(async () => { - await dispatchKeyupEvent(input); - expect(input.selectionStart).toBe(2); - expect(input.selectionEnd).toBe(5); - unmount(); - dispose(); - }); - })); + flush(); + expect(input.selectionStart).toBe(2); + expect(input.selectionEnd).toBe(5); + unmount(); + dispose(); + }); + }); - it("reads selection from textarea", () => - createRoot(async dispose => { + it("reads selection from textarea", () => { + createRoot(dispose => { const [selection] = createSelection(); expect(selection()).toEqual([null, NaN, NaN]); const { container, unmount } = renderTest(() => ); @@ -68,33 +60,31 @@ describe("createSelection", () => { expect(textarea).toBeInstanceOf(HTMLTextAreaElement); textarea.focus(); textarea.setSelectionRange(2, 5); - // only wrapped in createEffect, we will subscribe to changes - await createEffect(async () => { - await dispatchKeyupEvent(textarea); - expect(selection()).toEqual([textarea, 2, 5]); - unmount(); - dispose(); - }); - })); + textarea.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false })); + flush(); + expect(selection()).toEqual([textarea, 2, 5]); + unmount(); + dispose(); + }); + }); - it("writes selection to a textarea", () => - createRoot(async dispose => { - const [_selection, setSelection] = createSelection(); + it("writes selection to a textarea", () => { + createRoot(dispose => { + const [, setSelection] = createSelection(); const { container, unmount } = renderTest(() => ); const textarea = container.querySelector("textarea") as HTMLTextAreaElement; expect(textarea).toBeInstanceOf(HTMLTextAreaElement); setSelection([textarea, 2, 5]); - await createEffect(async () => { - await dispatchKeyupEvent(textarea); - expect(textarea.selectionStart).toBe(2); - expect(textarea.selectionEnd).toBe(5); - unmount(); - dispose(); - }); - })); + flush(); + expect(textarea.selectionStart).toBe(2); + expect(textarea.selectionEnd).toBe(5); + unmount(); + dispose(); + }); + }); - it("reads selection from contentEditable div", () => - createRoot(async dispose => { + it("reads selection from contentEditable div", () => { + createRoot(dispose => { const [selection] = createSelection(); expect(selection()).toEqual([null, NaN, NaN]); const { container, unmount } = renderTest(() =>
testing
); @@ -108,36 +98,32 @@ describe("createSelection", () => { document.createRange(), ), ); - // only wrapped in createEffect, we will subscribe to changes - await createEffect(async () => { - await dispatchKeyupEvent(div); - // might be delayed because of JSDOM - if (selection()[0] !== null) { - expect(selection()).toEqual([div, 0, 6]); - unmount(); - dispose(); - } - }); - })); + div.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false })); + flush(); + if (selection()[0] !== null) { + expect(selection()).toEqual([div, 0, 6]); + } + unmount(); + dispose(); + }); + }); - it("writes selection to a contentEditable div", () => - createRoot(async dispose => { + it("writes selection to a contentEditable div", () => { + createRoot(dispose => { const [selection, setSelection] = createSelection(); const { container, unmount } = renderTest(() =>
testing
); const div = container.querySelector("div") as HTMLDivElement; expect(div).toBeInstanceOf(HTMLDivElement); setSelection([div, 2, 5]); - await createEffect(async () => { - await dispatchKeyupEvent(div); - // might be delayed because of JSDOM - if (selection()[0] !== null) { - const range = window.getSelection()?.getRangeAt(0); - expect(range?.startContainer).toBe(div); - expect(range?.startOffset).toBe(2); - expect(range?.endOffset).toBe(5); - unmount(); - dispose(); - } - }); - })); + flush(); + if (selection()[0] !== null) { + const range = window.getSelection()?.getRangeAt(0); + expect(range?.startContainer).toBe(div.firstChild); + expect(range?.startOffset).toBe(2); + expect(range?.endOffset).toBe(5); + } + unmount(); + dispose(); + }); + }); }); diff --git a/packages/selection/test/server.test.ts b/packages/selection/test/server.test.ts new file mode 100644 index 000000000..96209f671 --- /dev/null +++ b/packages/selection/test/server.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; +import { createSelection } from "../src/index.js"; + +describe("createSelection (server)", () => { + it("returns no-op accessor and setter", () => { + const [selection, setSelection] = createSelection(); + expect(selection()).toEqual([null, NaN, NaN]); + setSelection([null, NaN, NaN]); + expect(selection()).toEqual([null, NaN, NaN]); + }); +}); diff --git a/packages/selection/tsconfig.json b/packages/selection/tsconfig.json index 38c71ce71..dc1970e16 100644 --- a/packages/selection/tsconfig.json +++ b/packages/selection/tsconfig.json @@ -5,7 +5,11 @@ "outDir": "dist", "rootDir": "src" }, - "references": [], + "references": [ + { + "path": "../utils" + } + ], "include": [ "src" ] diff --git a/packages/selection/vitest.config.ts b/packages/selection/vitest.config.ts new file mode 100644 index 000000000..d32a8b47c --- /dev/null +++ b/packages/selection/vitest.config.ts @@ -0,0 +1,73 @@ +import { defineConfig } from "vitest/config"; +import type { Plugin } from "vite"; + +function solidBabelPlugin(testSSR: boolean): Plugin { + return { + name: "solid-babel-transform", + config() { + return { esbuild: { jsx: "preserve" } }; + }, + async transform(source, id) { + if (!/\.[mc]?[tj]sx$/i.test(id) || /node_modules/.test(id)) return null; + id = id.replace(/\?.*$/, ""); + + const { transformAsync } = await import("@babel/core"); + const babelSolid = (await import("babel-preset-solid")).default; + + const parserPlugins: string[] = ["jsx"]; + if (/\.[mc]?tsx$/i.test(id)) parserPlugins.push("typescript"); + + const result = await transformAsync(source, { + filename: id, + sourceFileName: id, + presets: [ + [ + babelSolid, + { + moduleName: "@solidjs/web", + generate: testSSR ? "ssr" : "dom", + omitNestedClosingTags: false, + }, + ], + ], + plugins: [], + ast: false, + sourceMaps: true, + configFile: false, + babelrc: false, + parserOpts: { plugins: parserPlugins as any }, + }); + + if (!result || !result.code) return null; + return { code: result.code, map: result.map }; + }, + }; +} + +export default defineConfig(({ mode }) => { + const testSSR = mode === "test:ssr" || mode === "ssr"; + + return { + plugins: [solidBabelPlugin(testSSR)], + test: { + watch: false, + isolate: false, + passWithNoTests: true, + environment: testSSR ? "node" : "jsdom", + transformMode: { + web: [/\.[jt]sx$/], + }, + ...(testSSR + ? { include: ["test/server.test.{ts,tsx}"] } + : { + include: ["test/*.test.{ts,tsx}"], + exclude: ["test/server.test.{ts,tsx}"], + }), + }, + resolve: { + conditions: testSSR + ? ["@solid-primitives/source", "node"] + : ["@solid-primitives/source", "browser", "development"], + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70787571b..948952d99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -859,10 +859,23 @@ importers: version: 1.9.7 packages/selection: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils devDependencies: + '@babel/core': + specifier: ^7.27.0 + version: 7.29.0 + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) + babel-preset-solid: + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(@babel/core@7.29.0)(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/set: dependencies: