From 94ff87244fa52ef903ffad62eb85ee75682472fc Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:32:01 -0400 Subject: [PATCH 1/3] Initial conversion to 2.0 --- .changeset/upload-solid-2-migration.md | 10 + packages/upload/README.md | 97 ++++++++-- packages/upload/dev/index.tsx | 5 +- packages/upload/package.json | 12 +- packages/upload/src/createDropzone.ts | 18 +- packages/upload/src/createFileUploader.ts | 9 +- packages/upload/src/index.ts | 59 +++--- packages/upload/test/index.test.tsx | 225 +++++++++++++++++++++- pnpm-lock.yaml | 88 +++++++-- 9 files changed, 425 insertions(+), 98 deletions(-) create mode 100644 .changeset/upload-solid-2-migration.md diff --git a/.changeset/upload-solid-2-migration.md b/.changeset/upload-solid-2-migration.md new file mode 100644 index 000000000..55476d08c --- /dev/null +++ b/.changeset/upload-solid-2-migration.md @@ -0,0 +1,10 @@ +--- +"@solid-primitives/upload": minor +--- + +Migrate to Solid.js 2.0 (beta.7) + +- Updated peer dependencies to `solid-js@^2.0.0-beta.7` and `@solidjs/web@^2.0.0-beta.7` +- `isServer` is now imported from `@solidjs/web` (moved out of `solid-js/web`) +- `createDropzone`: replaced `onMount`/`onCleanup` with `onSettled` (returns cleanup function) per Solid 2.0 lifecycle API +- `fileUploader`: replaced the `use:fileUploader` directive (removed in Solid 2.0) with a **ref callback factory** — use `ref={fileUploader(opts)}` instead of `use:fileUploader={opts}` diff --git a/packages/upload/README.md b/packages/upload/README.md index c3a0227af..3ff483a4d 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -16,49 +16,63 @@ Primitive to make uploading files and making dropzones easier. npm install @solid-primitives/upload # or yarn add @solid-primitives/upload +# or +pnpm add @solid-primitives/upload ``` +> **Requires Solid.js 2.0 and `@solidjs/web` 2.0.** + ## How to use it ### [createFileUploader](#createfileuploader) +A reactive primitive that opens the OS file-picker dialog and exposes the selected files as a signal. + ```ts -// single files +// single file const { files, selectFiles } = createFileUploader(); -selectFiles([file] => console.log(file)); +selectFiles(([file]) => console.log(file)); -// multiple files +// multiple files with accept filter const { files, selectFiles } = createFileUploader({ multiple: true, accept: "image/*" }); selectFiles(files => files.forEach(file => console.log(file))); ``` -### use:fileUploader directive +**Returns:** -```ts +| Name | Type | Description | +| ------------- | ----------------------------------------- | ------------------------------------------- | +| `files` | `Accessor` | Reactive list of selected files | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback| +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all selected files | + +### [fileUploader](#fileuploader-ref-callback) + +A **ref callback factory** for `` elements (replaces the Solid 1.x `use:fileUploader` directive). + +```tsx const [files, setFiles] = createSignal([]); fs.forEach(f => console.log(f)), setFiles, - }} + })} />; ``` +> **Migration note (Solid 2.0):** The `use:fileUploader` directive syntax has been removed. +> Replace `use:fileUploader={opts}` with `ref={fileUploader(opts)}`. + ### [createDropzone](#createdropzone) -```html -
- Dropzone -
-``` +A reactive primitive for drag-and-drop file targets. -```ts -const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({ +```tsx +const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone({ onDrop: async files => { await doStuff(2); files.forEach(f => console.log(f)); @@ -66,6 +80,57 @@ const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({ onDragStart: files => files.forEach(f => console.log(f)), onDragOver: files => console.log("drag over"), }); + +
+ Dropzone +
+``` + +**Returns:** + +| Name | Type | Description | +| ------------- | ----------------------------- | --------------------------------------------- | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | +| `files` | `Accessor` | Reactive list of dropped files | +| `isDragging` | `Accessor` | `true` while a drag is in progress | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all dropped files | + +**DropzoneOptions:** + +| Callback | Type | Description | +| ------------- | -------------- | -------------------------------------- | +| `onDrop` | `UserCallback` | Fired when files are dropped | +| `onDragStart` | `UserCallback` | Fired when a drag starts | +| `onDragEnter` | `UserCallback` | Fired when dragged item enters element | +| `onDragEnd` | `UserCallback` | Fired when drag ends | +| `onDragLeave` | `UserCallback` | Fired when dragged item leaves element | +| `onDragOver` | `UserCallback` | Fired continuously while dragging over | +| `onDrag` | `UserCallback` | Fired on drag events | + +## Types + +```ts +type UploadFile = { + source: string; // blob URL (URL.createObjectURL) + name: string; + size: number; + file: File; +}; + +type UserCallback = (files: UploadFile[]) => void | Promise; + +type FileUploaderOptions = { + accept?: string; + multiple?: boolean; +}; + +type FileUploaderDirective = { + userCallback: UserCallback; + setFiles: Setter; +}; ``` ## Demo diff --git a/packages/upload/dev/index.tsx b/packages/upload/dev/index.tsx index 34065df6e..17f78c8ae 100644 --- a/packages/upload/dev/index.tsx +++ b/packages/upload/dev/index.tsx @@ -5,7 +5,6 @@ import { doStuff } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; -fileUploader; const SingleFileUpload: Component = () => { const { files, selectFiles } = createFileUploader(); @@ -99,10 +98,10 @@ const FileUploaderDirective: Component = () => { fs.forEach(f => console.log(f)), setFiles, - }} + })} /> {file =>

{file.name}

}
diff --git a/packages/upload/package.json b/packages/upload/package.json index 404cdcf54..380906c6e 100644 --- a/packages/upload/package.json +++ b/packages/upload/package.json @@ -1,6 +1,6 @@ { "name": "@solid-primitives/upload", - "version": "0.1.4", + "version": "0.2.0", "description": "Primitives for uploading files.", "author": "Rustam Ashurmatov ", "license": "MIT", @@ -48,14 +48,14 @@ "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, - "dependencies": { - "@solid-primitives/utils": "workspace:^" - }, + "dependencies": {}, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.7", + "solid-js": "^2.0.0-beta.7" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.7", + "solid-js": "2.0.0-beta.7" } } diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index 18f17694c..cec49d7d3 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,6 +1,5 @@ -import { createSignal, type JSX, onCleanup, onMount } from "solid-js"; -import { isServer } from "solid-js/web"; -import { noop } from "@solid-primitives/utils"; +import { createSignal, type JSX, onSettled } from "solid-js"; +import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; @@ -32,11 +31,11 @@ function createDropzone( ): Dropzone { if (isServer) { return { - setRef: noop, + setRef: () => {}, files: () => [], isDragging: () => false, - removeFile: noop, - clearFiles: noop, + removeFile: () => {}, + clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); @@ -80,10 +79,9 @@ function createDropzone( Promise.resolve(options?.onDrop?.(parsedFiles)); }; - onMount(() => { + onSettled(() => { if (!ref) return; - // TODO: Should event.stopPropagation() or event.preventDefault() in handlers below? ref.addEventListener("dragstart", onDragStart as any); ref.addEventListener("dragenter", onDragEnter as any); ref.addEventListener("dragend", onDragEnd as any); @@ -92,7 +90,7 @@ function createDropzone( ref.addEventListener("drag", onDrag as any); ref.addEventListener("drop", onDrop as any); - onCleanup(() => { + return () => { ref?.removeEventListener("dragstart", onDragStart as any); ref?.removeEventListener("dragenter", onDragEnter as any); ref?.removeEventListener("dragend", onDragEnd as any); @@ -100,7 +98,7 @@ function createDropzone( ref?.removeEventListener("dragover", onDragOver as any); ref?.removeEventListener("drag", onDrag as any); ref?.removeEventListener("drop", onDrop as any); - }); + }; }); const removeFile = (fileName: string) => { diff --git a/packages/upload/src/createFileUploader.ts b/packages/upload/src/createFileUploader.ts index 4edd075c2..dd2fcf1bc 100644 --- a/packages/upload/src/createFileUploader.ts +++ b/packages/upload/src/createFileUploader.ts @@ -1,6 +1,5 @@ import { createSignal, type JSX } from "solid-js"; -import { isServer } from "solid-js/web"; -import { noop } from "@solid-primitives/utils"; +import { isServer } from "@solidjs/web"; import { transformFiles, createInputComponent } from "./helpers.js"; import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from "./types.js"; @@ -27,9 +26,9 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { if (isServer) { return { files: () => [], - selectFiles: noop, - removeFile: noop, - clearFiles: noop, + selectFiles: () => {}, + removeFile: () => {}, + clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index 7f2698154..38cb6bc48 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,41 +1,36 @@ -import { type JSX, onCleanup, onMount } from "solid-js"; -import { isServer } from "solid-js/web"; +import { onCleanup } from "solid-js"; +import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import { type FileUploaderDirective } from "./types.js"; -declare module "solid-js" { - namespace JSX { - interface Directives { - fileUploader: FileUploaderDirective; +/** + * Ref callback factory for `` elements. + * + * Usage: `` + */ +export const fileUploader = (options: FileUploaderDirective) => { + if (isServer) return (_el: HTMLInputElement) => {}; + + const { userCallback, setFiles } = options; + let element: HTMLInputElement | undefined; + + const onChange = async (event: Event) => { + const target = event.currentTarget as HTMLInputElement; + const parsedFiles = transformFiles(target.files); + setFiles(parsedFiles); + try { + await userCallback(parsedFiles); + } catch (error) { + console.error(error); } - } -} + }; -export const fileUploader = (element: HTMLInputElement, options: () => FileUploaderDirective) => { - if (isServer) { - return; - } - const { userCallback, setFiles } = options(); + onCleanup(() => element?.removeEventListener("change", onChange)); - onMount(() => { - const onChange: JSX.EventHandler = async event => { - const parsedFiles = transformFiles(event.currentTarget.files); - - setFiles(parsedFiles); - - try { - await userCallback(parsedFiles); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - } - return; - }; - - onCleanup(() => element.removeEventListener("change", onChange as any)); - - element.addEventListener("change", onChange as any); - }); + return (el: HTMLInputElement) => { + element = el; + el.addEventListener("change", onChange); + }; }; export { createFileUploader } from "./createFileUploader.js"; diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 6d1c90e17..0ba0d5499 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -1,16 +1,225 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeAll } from "vitest"; import { createRoot } from "solid-js"; -import { createFileUploader } from "../src/index.js"; +import { createFileUploader, createDropzone, fileUploader } from "../src/index.js"; +import { transformFiles } from "../src/helpers.js"; +import type { UploadFile } from "../src/types.js"; + +// ── jsdom polyfills ─────────────────────────────────────────────────────────── + +beforeAll(() => { + // jsdom does not implement createObjectURL + vi.stubGlobal("URL", { createObjectURL: (f: File) => `blob:${f.name}` }); +}); + +// Creates a minimal FileList-alike without DataTransfer (not in jsdom) +function makeFileList(...files: File[]): FileList { + const fl: Record = {}; + files.forEach((f, i) => (fl[i] = f)); + fl.length = files.length; + return fl as unknown as FileList; +} + +function makeFile(name = "test.png", size = 64, type = "image/png"): File { + return new File(["x".repeat(size)], name, { type }); +} + +// ── transformFiles ──────────────────────────────────────────────────────────── + +describe("transformFiles", () => { + it("returns empty array for null input", () => { + expect(transformFiles(null)).toEqual([]); + }); + + it("converts a FileList to UploadFile array", () => { + const file = makeFile("hello.png", 512); + const result = transformFiles(makeFileList(file)); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("hello.png"); + expect(result[0]!.size).toBe(512); + expect(result[0]!.file).toBe(file); + expect(typeof result[0]!.source).toBe("string"); + }); + + it("converts multiple files", () => { + const result = transformFiles(makeFileList(makeFile("a.png"), makeFile("b.jpg"))); + expect(result).toHaveLength(2); + expect(result.map(f => f.name)).toEqual(["a.png", "b.jpg"]); + }); +}); + +// ── createFileUploader ──────────────────────────────────────────────────────── describe("createFileUploader", () => { - it("file upload", () => { - createRoot(dispose => { - const { files: file } = createFileUploader(); - const { files } = createFileUploader({ multiple: true }); + it("initialises with empty file list", () => { + const { files, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + expect(files()).toEqual([]); + dispose(); + }); + + it("initialises with empty file list (multiple mode)", () => { + const { files, dispose } = createRoot(dispose => ({ + ...createFileUploader({ multiple: true }), + dispose, + })); + expect(files()).toEqual([]); + dispose(); + }); + + it("removeFile filters by name", () => { + // In Solid 2.0, signal writes must occur outside owned scopes. + // We pull the API out of createRoot, then call it at the test level. + const { files, removeFile, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + expect(files()).toEqual([]); + removeFile("nonexistent.png"); // no-op, should not throw + expect(files()).toEqual([]); + dispose(); + }); + + it("clearFiles empties the list", () => { + const { files, clearFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + clearFiles(); + expect(files()).toEqual([]); + dispose(); + }); + + it("selectFiles triggers a file input click", () => { + const { selectFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + + const clickSpy = vi.fn(); + const origCreate = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") vi.spyOn(el as HTMLInputElement, "click").mockImplementation(clickSpy); + return el; + }); + + selectFiles(() => {}); + expect(clickSpy).toHaveBeenCalledOnce(); + + vi.restoreAllMocks(); + dispose(); + }); +}); - expect(file()).toEqual([]); - expect(files()).toEqual([]); +// ── createDropzone ──────────────────────────────────────────────────────────── + +describe("createDropzone", () => { + it("initialises with empty files and isDragging=false", () => { + const { files, isDragging, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + expect(files()).toEqual([]); + expect(isDragging()).toBe(false); + dispose(); + }); + + it("exposes setRef, removeFile, clearFiles", () => { + const { setRef, removeFile, clearFiles, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + expect(typeof setRef).toBe("function"); + expect(typeof removeFile).toBe("function"); + expect(typeof clearFiles).toBe("function"); + dispose(); + }); + + it("clearFiles empties the list", () => { + const { files, clearFiles, dispose } = createRoot(dispose => ({ + ...createDropzone(), + dispose, + })); + clearFiles(); + expect(files()).toEqual([]); + dispose(); + }); + + it("accepts DropzoneOptions callbacks without throwing", () => { + expect(() => { + const dispose = createRoot(dispose => { + createDropzone({ + onDrop: vi.fn(), + onDragStart: vi.fn(), + onDragEnter: vi.fn(), + onDragEnd: vi.fn(), + onDragLeave: vi.fn(), + onDragOver: vi.fn(), + onDrag: vi.fn(), + }); + return dispose; + }); + dispose(); + }).not.toThrow(); + }); +}); + +// ── fileUploader ref callback ───────────────────────────────────────────────── + +describe("fileUploader", () => { + it("returns a function (ref callback)", () => { + const ref = createRoot(dispose => { + const r = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); + dispose(); + return r; + }); + expect(typeof ref).toBe("function"); + }); + + it("attaches a change listener to the element", () => { + const input = document.createElement("input"); + input.type = "file"; + const addSpy = vi.spyOn(input, "addEventListener"); + + const ref = createRoot(dispose => { + const r = fileUploader({ userCallback: vi.fn(), setFiles: vi.fn() }); + dispose(); + return r; + }); + + ref(input); + expect(addSpy).toHaveBeenCalledWith("change", expect.any(Function)); + }); + + it("calls setFiles and userCallback on change event", async () => { + const input = document.createElement("input"); + input.type = "file"; + const setFiles = vi.fn(); + const userCallback = vi.fn(); + + const ref = createRoot(dispose => { + const r = fileUploader({ userCallback, setFiles }); dispose(); + return r; }); + + ref(input); + + const file = makeFile("upload.png"); + Object.defineProperty(input, "files", { value: makeFileList(file), configurable: true }); + + const event = new Event("change"); + Object.defineProperty(event, "currentTarget", { value: input, configurable: true }); + input.dispatchEvent(event); + + await Promise.resolve(); + + expect(setFiles).toHaveBeenCalledOnce(); + const uploaded: UploadFile[] = setFiles.mock.calls[0]![0]; + expect(uploaded).toHaveLength(1); + expect(uploaded[0]!.name).toBe("upload.png"); + expect(userCallback).toHaveBeenCalledOnce(); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecadfdb95..5b7dd9a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -964,9 +964,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.7 + version: 2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.7 + version: 2.0.0-beta.7 packages/utils: devDependencies: @@ -1048,10 +1051,10 @@ importers: version: link:../packages/utils '@solidjs/meta': specifier: ^0.29.3 - version: 0.29.4(solid-js@1.9.7) + version: 0.29.4(solid-js@2.0.0-beta.7) '@solidjs/router': specifier: ^0.13.1 - version: 0.13.6(solid-js@1.9.7) + version: 0.13.6(solid-js@2.0.0-beta.7) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1078,13 +1081,13 @@ importers: version: 1.77.8 solid-dismiss: specifier: ^1.7.121 - version: 1.8.2(solid-js@1.9.7) + version: 1.8.2(solid-js@2.0.0-beta.7) solid-icons: specifier: ^1.1.0 - version: 1.1.0(solid-js@1.9.7) + version: 1.1.0(solid-js@2.0.0-beta.7) solid-tippy: specifier: ^0.2.1 - version: 0.2.1(solid-js@1.9.7)(tippy.js@6.3.7) + version: 0.2.1(solid-js@2.0.0-beta.7)(tippy.js@6.3.7) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -2042,6 +2045,7 @@ packages: '@graphql-tools/prisma-loader@8.0.4': resolution: {integrity: sha512-hqKPlw8bOu/GRqtYr0+dINAI13HinTVYBDqhwGAPIFmLr5s+qKskzgCiwbsckdrb5LWVFmVZc+UXn80OGiyBzg==} engines: {node: '>=16.0.0'} + deprecated: 'This package was intended to be used with an older versions of Prisma.\nThe newer versions of Prisma has a different approach to GraphQL integration.\nTherefore, this package is no longer needed and has been deprecated and removed.\nLearn more: https://www.prisma.io/graphql' peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 @@ -2587,11 +2591,20 @@ packages: peerDependencies: solid-js: ^1.5.3 + '@solidjs/signals@2.0.0-beta.7': + resolution: {integrity: sha512-SgK6oQlQZofz82LiEJ2RzT3sbs1lWTqFEtLoWjLsUo/dk1v9EoIFpJJlmvgkXvNugASWG+l1yOHa1a8lPamxug==} + '@solidjs/start@1.1.4': resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==} peerDependencies: vinxi: ^0.5.3 + '@solidjs/web@2.0.0-beta.7': + resolution: {integrity: sha512-m5VjmDBufrOX0ZKGbhvwkT0CPK0TbMxDbxVPDB1PH2evGbWXQZcUlrpFM1N8RBO5md3aR/T1PgMfnOjleJbrRg==} + peerDependencies: + '@solidjs/signals': ^2.0.0-beta.7 + solid-js: ^2.0.0-beta.7 + '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -3513,6 +3526,7 @@ packages: dax-sh@0.43.2: resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' db0@0.3.2: resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} @@ -4164,11 +4178,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -5892,10 +5907,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + serve-placeholder@2.0.2: resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} @@ -6001,6 +6026,9 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + solid-js@2.0.0-beta.7: + resolution: {integrity: sha512-7JHs+BhLeZXoU+u9dG+eKnyxxfZyGpOuJEBbN/1XbHKO/WhxecdplOAurlg/YDllNWPhsbXqmLR1H2paqSu62g==} + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -6195,6 +6223,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -6710,6 +6739,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -8576,18 +8606,20 @@ snapshots: dependencies: solid-js: 1.9.7 - '@solidjs/meta@0.29.4(solid-js@1.9.7)': + '@solidjs/meta@0.29.4(solid-js@2.0.0-beta.7)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 - '@solidjs/router@0.13.6(solid-js@1.9.7)': + '@solidjs/router@0.13.6(solid-js@2.0.0-beta.7)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 '@solidjs/router@0.8.4(solid-js@1.9.7)': dependencies: solid-js: 1.9.7 + '@solidjs/signals@2.0.0-beta.7': {} + '@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) @@ -8611,6 +8643,13 @@ snapshots: - supports-color - vite + '@solidjs/web@2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7)': + dependencies: + '@solidjs/signals': 2.0.0-beta.7 + seroval: 1.3.2 + seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js: 2.0.0-beta.7 + '@speed-highlight/core@1.2.7': {} '@supabase/auth-js@2.67.3': @@ -12441,8 +12480,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + seroval@1.3.2: {} + seroval@1.5.2: {} + serve-placeholder@2.0.2: dependencies: defu: 6.1.4 @@ -12557,13 +12602,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - solid-dismiss@1.8.2(solid-js@1.9.7): + solid-dismiss@1.8.2(solid-js@2.0.0-beta.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 - solid-icons@1.1.0(solid-js@1.9.7): + solid-icons@1.1.0(solid-js@2.0.0-beta.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 solid-js@1.9.7: dependencies: @@ -12571,6 +12616,13 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js@2.0.0-beta.7: + dependencies: + '@solidjs/signals': 2.0.0-beta.7 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-refresh@0.6.3(solid-js@1.9.7): dependencies: '@babel/generator': 7.27.5 @@ -12580,9 +12632,9 @@ snapshots: transitivePeerDependencies: - supports-color - solid-tippy@0.2.1(solid-js@1.9.7)(tippy.js@6.3.7): + solid-tippy@0.2.1(solid-js@2.0.0-beta.7)(tippy.js@6.3.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-beta.7 tippy.js: 6.3.7 solid-transition-group@0.2.3(solid-js@1.9.7): From 5ea5b7590cbf7bb3ab43d823155850aa11e83b90 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:46:29 -0400 Subject: [PATCH 2/3] Added better error handling and minor package cleanup --- packages/upload/README.md | 33 +++-- packages/upload/src/createDropzone.ts | 71 ++++++----- packages/upload/src/createFileUploader.ts | 15 ++- packages/upload/src/fileUploader.ts | 41 ++++++ packages/upload/src/index.ts | 36 +----- packages/upload/src/types.ts | 4 +- packages/upload/test/index.test.tsx | 144 +++++++++++++++++++--- 7 files changed, 241 insertions(+), 103 deletions(-) create mode 100644 packages/upload/src/fileUploader.ts diff --git a/packages/upload/README.md b/packages/upload/README.md index 3ff483a4d..2f8369934 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -40,12 +40,13 @@ selectFiles(files => files.forEach(file => console.log(file))); **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------------------- | ------------------------------------------- | -| `files` | `Accessor` | Reactive list of selected files | -| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback| -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all selected files | +| Name | Type | Description | +| ------------- | ----------------------------------------- | ------------------------------------------------------------------ | +| `files` | `Accessor` | Reactive list of selected files | +| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all selected files | ### [fileUploader](#fileuploader-ref-callback) @@ -53,6 +54,7 @@ A **ref callback factory** for `` elements (replaces the Soli ```tsx const [files, setFiles] = createSignal([]); +const [uploadError, setUploadError] = createSignal(null); ([]); ref={fileUploader({ userCallback: fs => fs.forEach(f => console.log(f)), setFiles, + onError: err => setUploadError(err), })} />; ``` +If `onError` is omitted, a rejection from `userCallback` propagates as an unhandled promise rejection. + > **Migration note (Solid 2.0):** The `use:fileUploader` directive syntax has been removed. > Replace `use:fileUploader={opts}` with `ref={fileUploader(opts)}`. @@ -90,13 +95,14 @@ const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone( **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------- | --------------------------------------------- | -| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | -| `files` | `Accessor` | Reactive list of dropped files | -| `isDragging` | `Accessor` | `true` while a drag is in progress | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all dropped files | +| Name | Type | Description | +| ------------- | ----------------------------- | ------------------------------------------------------------------ | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | +| `files` | `Accessor` | Reactive list of dropped files | +| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | +| `isDragging` | `Accessor` | `true` while a drag is in progress | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all dropped files | **DropzoneOptions:** @@ -130,6 +136,7 @@ type FileUploaderOptions = { type FileUploaderDirective = { userCallback: UserCallback; setFiles: Setter; + onError?: (error: unknown) => void; }; ``` diff --git a/packages/upload/src/createDropzone.ts b/packages/upload/src/createDropzone.ts index cec49d7d3..6b568519a 100644 --- a/packages/upload/src/createDropzone.ts +++ b/packages/upload/src/createDropzone.ts @@ -1,4 +1,4 @@ -import { createSignal, type JSX, onSettled } from "solid-js"; +import { createSignal, type JSX, getOwner, onCleanup, runWithOwner } from "solid-js"; import { isServer } from "@solidjs/web"; import { transformFiles } from "./helpers.js"; import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; @@ -8,19 +8,18 @@ import type { UploadFile, Dropzone, DropzoneOptions } from "./types.js"; * * @returns `setRef` * @returns `files` + * @returns `error` - Reactive error from the last drag callback, cleared on next drop * @returns `isDragging` * @returns `removeFile` * @returns `clearFiles` * * @example * ```ts - * // run async user callback - * const { setRef: dropzoneRef1, files: droppedFiles1 } = createDropzone({ + * const { setRef: dropzoneRef, files: droppedFiles, error } = createDropzone({ * onDrop: async files => { * await doStuff(2); * files.forEach(f => console.log(f)); * }, - * onDragStart: files => console.log("drag start") * onDragStart: files => files.forEach(f => console.log(f)), * onDragOver: files => console.log("drag over") * }); @@ -33,55 +32,62 @@ function createDropzone( return { setRef: () => {}, files: () => [], + error: () => null, isDragging: () => false, removeFile: () => {}, clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); + const [error, setError] = createSignal(null); const [isDragging, setIsDragging] = createSignal(false); - let ref: T | undefined = undefined; + // Capture owner in the Phase-1 (owned) scope so cleanup can be registered later + const owner = getOwner(); - const setRef = (r: T) => { - ref = r; + const runCallback = async ( + callback: ((files: UploadFile[]) => void | Promise) | undefined, + parsedFiles: UploadFile[], + ) => { + try { + await callback?.(parsedFiles); + } catch (err) { + setError(err); + } }; const onDragStart: JSX.EventHandler = event => { setIsDragging(true); - Promise.resolve(options?.onDragStart?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragStart, transformFiles(event.dataTransfer?.files || null)); }; const onDragEnd: JSX.EventHandler = event => { setIsDragging(false); - Promise.resolve(options?.onDragEnd?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragEnd, transformFiles(event.dataTransfer?.files || null)); }; - const onDragEnter: JSX.EventHandler = event => { - Promise.resolve(options?.onDragEnter?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragEnter, transformFiles(event.dataTransfer?.files || null)); }; const onDragLeave: JSX.EventHandler = event => { - Promise.resolve(options?.onDragLeave?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragLeave, transformFiles(event.dataTransfer?.files || null)); }; const onDragOver: JSX.EventHandler = event => { event.preventDefault(); - Promise.resolve(options?.onDragOver?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDragOver, transformFiles(event.dataTransfer?.files || null)); }; const onDrag: JSX.EventHandler = event => { - Promise.resolve(options?.onDrag?.(transformFiles(event.dataTransfer?.files || null))); + void runCallback(options?.onDrag, transformFiles(event.dataTransfer?.files || null)); }; - const onDrop: JSX.EventHandler = event => { event.preventDefault(); - const parsedFiles = transformFiles(event.dataTransfer?.files || null); setFiles(parsedFiles); - - Promise.resolve(options?.onDrop?.(parsedFiles)); + setError(null); + void runCallback(options?.onDrop, parsedFiles); }; - onSettled(() => { - if (!ref) return; - + // setRef is the Phase-2 ref callback: called synchronously when the element is created. + // Listeners are attached immediately; cleanup is registered back in the owner scope. + const setRef = (ref: T) => { ref.addEventListener("dragstart", onDragStart as any); ref.addEventListener("dragenter", onDragEnter as any); ref.addEventListener("dragend", onDragEnd as any); @@ -90,16 +96,18 @@ function createDropzone( ref.addEventListener("drag", onDrag as any); ref.addEventListener("drop", onDrop as any); - return () => { - ref?.removeEventListener("dragstart", onDragStart as any); - ref?.removeEventListener("dragenter", onDragEnter as any); - ref?.removeEventListener("dragend", onDragEnd as any); - ref?.removeEventListener("dragleave", onDragLeave as any); - ref?.removeEventListener("dragover", onDragOver as any); - ref?.removeEventListener("drag", onDrag as any); - ref?.removeEventListener("drop", onDrop as any); - }; - }); + runWithOwner(owner, () => { + onCleanup(() => { + ref.removeEventListener("dragstart", onDragStart as any); + ref.removeEventListener("dragenter", onDragEnter as any); + ref.removeEventListener("dragend", onDragEnd as any); + ref.removeEventListener("dragleave", onDragLeave as any); + ref.removeEventListener("dragover", onDragOver as any); + ref.removeEventListener("drag", onDrag as any); + ref.removeEventListener("drop", onDrop as any); + }); + }); + }; const removeFile = (fileName: string) => { setFiles(prev => prev.filter(f => f.name !== fileName)); @@ -112,6 +120,7 @@ function createDropzone( return { setRef, files, + error, isDragging, removeFile, clearFiles, diff --git a/packages/upload/src/createFileUploader.ts b/packages/upload/src/createFileUploader.ts index dd2fcf1bc..e2c58a4de 100644 --- a/packages/upload/src/createFileUploader.ts +++ b/packages/upload/src/createFileUploader.ts @@ -7,6 +7,7 @@ import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from * Primitive to make uploading files easier. * * @returns `files` + * @returns `error` - Reactive error from the last `selectFiles` callback, cleared on next selection * @returns `selectFiles` - Open file picker, set files and run user callback * @returns `removeFile` * @returns `clearFiles` @@ -14,11 +15,11 @@ import type { FileUploader, FileUploaderOptions, UploadFile, UserCallback } from * @example * ```ts * // multiple files - * const {files, selectFiles} = createFileUploader({ multiple: true, accept: "image/*" }); + * const { files, error, selectFiles } = createFileUploader({ multiple: true, accept: "image/*" }); * selectFiles(files => files.forEach(file => console.log(file))); * * // single file - * const {file, selectFile} = createFileUploader(); + * const { files, error, selectFiles } = createFileUploader(); * selectFiles(([{ source, name, size, file }]) => console.log({ source, name, size, file })); * ``` */ @@ -26,12 +27,14 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { if (isServer) { return { files: () => [], + error: () => null, selectFiles: () => {}, removeFile: () => {}, clearFiles: () => {}, }; } const [files, setFiles] = createSignal([]); + const [error, setError] = createSignal(null); let userCallback: UserCallback = () => {}; @@ -50,14 +53,13 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { target.remove(); setFiles(parsedFiles); + setError(null); try { await userCallback(parsedFiles); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + } catch (err) { + setError(err); } - return; }; const selectFiles = (callback?: UserCallback) => { @@ -81,6 +83,7 @@ function createFileUploader(options?: FileUploaderOptions): FileUploader { return { files, + error, selectFiles, removeFile, clearFiles, diff --git a/packages/upload/src/fileUploader.ts b/packages/upload/src/fileUploader.ts new file mode 100644 index 000000000..db5e47924 --- /dev/null +++ b/packages/upload/src/fileUploader.ts @@ -0,0 +1,41 @@ +import { onCleanup } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { transformFiles } from "./helpers.js"; +import { type FileUploaderDirective } from "./types.js"; + +/** + * Ref callback factory for `` elements. + * + * If `onError` is provided it is called with the thrown value when `userCallback` rejects; + * otherwise the rejection propagates as an unhandled promise rejection. + * + * Usage: `` + */ +export const fileUploader = (options: FileUploaderDirective) => { + if (isServer) return (_el: HTMLInputElement) => {}; + + const { userCallback, setFiles, onError } = options; + let element: HTMLInputElement | undefined; + + const onChange = async (event: Event) => { + const target = event.currentTarget as HTMLInputElement; + const parsedFiles = transformFiles(target.files); + setFiles(parsedFiles); + try { + await userCallback(parsedFiles); + } catch (err) { + if (onError) { + onError(err); + } else { + throw err; + } + } + }; + + onCleanup(() => element?.removeEventListener("change", onChange)); + + return (el: HTMLInputElement) => { + element = el; + el.addEventListener("change", onChange); + }; +}; diff --git a/packages/upload/src/index.ts b/packages/upload/src/index.ts index 38cb6bc48..0c9d1da44 100644 --- a/packages/upload/src/index.ts +++ b/packages/upload/src/index.ts @@ -1,38 +1,4 @@ -import { onCleanup } from "solid-js"; -import { isServer } from "@solidjs/web"; -import { transformFiles } from "./helpers.js"; -import { type FileUploaderDirective } from "./types.js"; - -/** - * Ref callback factory for `` elements. - * - * Usage: `` - */ -export const fileUploader = (options: FileUploaderDirective) => { - if (isServer) return (_el: HTMLInputElement) => {}; - - const { userCallback, setFiles } = options; - let element: HTMLInputElement | undefined; - - const onChange = async (event: Event) => { - const target = event.currentTarget as HTMLInputElement; - const parsedFiles = transformFiles(target.files); - setFiles(parsedFiles); - try { - await userCallback(parsedFiles); - } catch (error) { - console.error(error); - } - }; - - onCleanup(() => element?.removeEventListener("change", onChange)); - - return (el: HTMLInputElement) => { - element = el; - el.addEventListener("change", onChange); - }; -}; - export { createFileUploader } from "./createFileUploader.js"; export { createDropzone } from "./createDropzone.js"; +export { fileUploader } from "./fileUploader.js"; export * from "./types.js"; diff --git a/packages/upload/src/types.ts b/packages/upload/src/types.ts index a1b235f29..aa2475c6d 100644 --- a/packages/upload/src/types.ts +++ b/packages/upload/src/types.ts @@ -20,10 +20,10 @@ export type FileUploaderOptions = { }; export type UserCallback = (files: UploadFile[]) => void | Promise; -export type UserCallback2 = () => void | Promise; export interface FileUploader { files: Accessor; + error: Accessor; selectFiles: (callback: (files: UploadFile[]) => void | Promise) => void; removeFile: (fileName: string) => void; clearFiles: () => void; @@ -32,11 +32,13 @@ export interface FileUploader { export type FileUploaderDirective = { userCallback: (files: UploadFile[]) => void | Promise; setFiles: Setter; + onError?: (error: unknown) => void; }; export interface Dropzone { setRef: (ref: T) => void; files: Accessor; + error: Accessor; isDragging: Accessor; removeFile: (fileName: string) => void; clearFiles: () => void; diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 0ba0d5499..826246e1c 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -23,6 +23,14 @@ function makeFile(name = "test.png", size = 64, type = "image/png"): File { return new File(["x".repeat(size)], name, { type }); } +// Dispatch a change event with the given files on an input element +function dispatchChange(input: HTMLInputElement, files: FileList) { + Object.defineProperty(input, "files", { value: files, configurable: true }); + const event = new Event("change"); + Object.defineProperty(event, "currentTarget", { value: input, configurable: true }); + input.dispatchEvent(event); +} + // ── transformFiles ──────────────────────────────────────────────────────────── describe("transformFiles", () => { @@ -50,12 +58,13 @@ describe("transformFiles", () => { // ── createFileUploader ──────────────────────────────────────────────────────── describe("createFileUploader", () => { - it("initialises with empty file list", () => { - const { files, dispose } = createRoot(dispose => ({ + it("initialises with empty file list and no error", () => { + const { files, error, dispose } = createRoot(dispose => ({ ...createFileUploader(), dispose, })); expect(files()).toEqual([]); + expect(error()).toBeNull(); dispose(); }); @@ -68,15 +77,12 @@ describe("createFileUploader", () => { dispose(); }); - it("removeFile filters by name", () => { - // In Solid 2.0, signal writes must occur outside owned scopes. - // We pull the API out of createRoot, then call it at the test level. + it("removeFile is a no-op on an empty list", () => { const { files, removeFile, dispose } = createRoot(dispose => ({ ...createFileUploader(), dispose, })); - expect(files()).toEqual([]); - removeFile("nonexistent.png"); // no-op, should not throw + removeFile("nonexistent.png"); expect(files()).toEqual([]); dispose(); }); @@ -111,17 +117,79 @@ describe("createFileUploader", () => { vi.restoreAllMocks(); dispose(); }); + + it("error() is null when callback succeeds", async () => { + const { error, selectFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + + const origCreate = document.createElement.bind(document); + let createdInput: HTMLInputElement | undefined; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") { + createdInput = el as HTMLInputElement; + vi.spyOn(createdInput, "click").mockImplementation(() => { + dispatchChange(createdInput!, makeFileList(makeFile())); + }); + } + return el; + }); + + selectFiles(async () => { + /* success */ + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(error()).toBeNull(); + vi.restoreAllMocks(); + dispose(); + }); + + it("error() captures a thrown callback error", async () => { + const boom = new Error("upload failed"); + const { error, selectFiles, dispose } = createRoot(dispose => ({ + ...createFileUploader(), + dispose, + })); + + const origCreate = document.createElement.bind(document); + let createdInput: HTMLInputElement | undefined; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreate(tag); + if (tag === "input") { + createdInput = el as HTMLInputElement; + vi.spyOn(createdInput, "click").mockImplementation(() => { + dispatchChange(createdInput!, makeFileList(makeFile())); + }); + } + return el; + }); + + selectFiles(async () => { + throw boom; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(error()).toBe(boom); + vi.restoreAllMocks(); + dispose(); + }); }); // ── createDropzone ──────────────────────────────────────────────────────────── describe("createDropzone", () => { - it("initialises with empty files and isDragging=false", () => { - const { files, isDragging, dispose } = createRoot(dispose => ({ + it("initialises with empty files, no error, and isDragging=false", () => { + const { files, error, isDragging, dispose } = createRoot(dispose => ({ ...createDropzone(), dispose, })); expect(files()).toEqual([]); + expect(error()).toBeNull(); expect(isDragging()).toBe(false); dispose(); }); @@ -164,6 +232,31 @@ describe("createDropzone", () => { dispose(); }).not.toThrow(); }); + + it("error() captures a thrown onDrop callback", async () => { + const boom = new Error("drop failed"); + const { error, setRef, dispose } = createRoot(dispose => ({ + ...createDropzone({ onDrop: async () => { throw boom; } }), + dispose, + })); + + const div = document.createElement("div"); + setRef(div); + + // jsdom does not implement DragEvent — dispatch a plain Event with stubbed dataTransfer + const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, "dataTransfer", { + value: { files: makeFileList(makeFile()) }, + configurable: true, + }); + div.dispatchEvent(dropEvent); + + await Promise.resolve(); + await Promise.resolve(); + + expect(error()).toBe(boom); + dispose(); + }); }); // ── fileUploader ref callback ───────────────────────────────────────────────── @@ -206,14 +299,7 @@ describe("fileUploader", () => { }); ref(input); - - const file = makeFile("upload.png"); - Object.defineProperty(input, "files", { value: makeFileList(file), configurable: true }); - - const event = new Event("change"); - Object.defineProperty(event, "currentTarget", { value: input, configurable: true }); - input.dispatchEvent(event); - + dispatchChange(input, makeFileList(makeFile("upload.png"))); await Promise.resolve(); expect(setFiles).toHaveBeenCalledOnce(); @@ -222,4 +308,28 @@ describe("fileUploader", () => { expect(uploaded[0]!.name).toBe("upload.png"); expect(userCallback).toHaveBeenCalledOnce(); }); + + it("calls onError when userCallback throws", async () => { + const boom = new Error("cb failed"); + const input = document.createElement("input"); + input.type = "file"; + const onError = vi.fn(); + + const ref = createRoot(dispose => { + const r = fileUploader({ + userCallback: async () => { throw boom; }, + setFiles: vi.fn(), + onError, + }); + dispose(); + return r; + }); + + ref(input); + dispatchChange(input, makeFileList(makeFile())); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith(boom); + }); }); From 3af985e676f3da435ee4d1b827d524df5c10b59f Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:46:40 -0400 Subject: [PATCH 3/3] Ran formatter --- packages/audio/dev/index.tsx | 4 +-- packages/geolocation/dev/client.tsx | 2 +- packages/upload/README.md | 41 ++++++++++++++++------------- packages/upload/dev/index.tsx | 1 - packages/upload/test/index.test.tsx | 10 +++++-- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/audio/dev/index.tsx b/packages/audio/dev/index.tsx index b9dea4595..6df2ad096 100644 --- a/packages/audio/dev/index.tsx +++ b/packages/audio/dev/index.tsx @@ -76,7 +76,7 @@ const App: Component = () => {
-
(ref = el)} /> +
(ref = el)} />
{location()?.latitude}, {location()?.longitude}
diff --git a/packages/upload/README.md b/packages/upload/README.md index 2f8369934..1609fd0ad 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -40,13 +40,13 @@ selectFiles(files => files.forEach(file => console.log(file))); **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------------------- | ------------------------------------------------------------------ | -| `files` | `Accessor` | Reactive list of selected files | -| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | -| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all selected files | +| Name | Type | Description | +| ------------- | ----------------------------------- | --------------------------------------------------------------- | +| `files` | `Accessor` | Reactive list of selected files | +| `error` | `Accessor` | Error thrown by the last `selectFiles` callback; `null` if none | +| `selectFiles` | `(callback?: UserCallback) => void` | Opens file-picker and runs optional callback | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all selected files | ### [fileUploader](#fileuploader-ref-callback) @@ -77,7 +77,11 @@ If `onError` is omitted, a rejection from `userCallback` propagates as an unhand A reactive primitive for drag-and-drop file targets. ```tsx -const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone({ +const { + setRef: dropzoneRef, + files: droppedFiles, + isDragging, +} = createDropzone({ onDrop: async files => { await doStuff(2); files.forEach(f => console.log(f)); @@ -88,21 +92,22 @@ const { setRef: dropzoneRef, files: droppedFiles, isDragging } = createDropzone(
+ style={{ width: "100px", height: "100px", background: isDragging() ? "green" : "red" }} +> Dropzone -
+
; ``` **Returns:** -| Name | Type | Description | -| ------------- | ----------------------------- | ------------------------------------------------------------------ | -| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | -| `files` | `Accessor` | Reactive list of dropped files | -| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | -| `isDragging` | `Accessor` | `true` while a drag is in progress | -| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | -| `clearFiles` | `() => void` | Clears all dropped files | +| Name | Type | Description | +| ------------ | ---------------------------- | ------------------------------------------------------ | +| `setRef` | `(el: T) => void` | Ref callback — pass to the `ref` prop of an element | +| `files` | `Accessor` | Reactive list of dropped files | +| `error` | `Accessor` | Error thrown by the last drag callback; `null` if none | +| `isDragging` | `Accessor` | `true` while a drag is in progress | +| `removeFile` | `(fileName: string) => void` | Removes a file by name from the list | +| `clearFiles` | `() => void` | Clears all dropped files | **DropzoneOptions:** diff --git a/packages/upload/dev/index.tsx b/packages/upload/dev/index.tsx index 17f78c8ae..cb8b02f54 100644 --- a/packages/upload/dev/index.tsx +++ b/packages/upload/dev/index.tsx @@ -5,7 +5,6 @@ import { doStuff } from "../src/helpers.js"; import type { UploadFile } from "../src/types.js"; - const SingleFileUpload: Component = () => { const { files, selectFiles } = createFileUploader(); const { files: filesAsync, selectFiles: selectFilesAsync } = createFileUploader(); diff --git a/packages/upload/test/index.test.tsx b/packages/upload/test/index.test.tsx index 826246e1c..cac65687d 100644 --- a/packages/upload/test/index.test.tsx +++ b/packages/upload/test/index.test.tsx @@ -236,7 +236,11 @@ describe("createDropzone", () => { it("error() captures a thrown onDrop callback", async () => { const boom = new Error("drop failed"); const { error, setRef, dispose } = createRoot(dispose => ({ - ...createDropzone({ onDrop: async () => { throw boom; } }), + ...createDropzone({ + onDrop: async () => { + throw boom; + }, + }), dispose, })); @@ -317,7 +321,9 @@ describe("fileUploader", () => { const ref = createRoot(dispose => { const r = fileUploader({ - userCallback: async () => { throw boom; }, + userCallback: async () => { + throw boom; + }, setFiles: vi.fn(), onError, });