From 6b48923e7940d70ec104ed2ea6ab22295cda26f7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 9 Feb 2026 16:54:32 -0500 Subject: [PATCH 1/2] feat: add SolidJS integration package (@effect-atom/atom-solid) SolidJS adapter for @effect-atom/atom with hooks: - useAtomValue, useAtom, useAtomSet, useAtomMount, useAtomRefresh - useAtomSubscribe, useAtomRef, useAtomRefProp, useAtomRefPropValue - useAtomInitialValues - RegistryProvider and RegistryContext - Comprehensive test suite (264 lines) --- packages/atom-solid/.gitignore | 14 ++ packages/atom-solid/.prettierignore | 3 + packages/atom-solid/CHANGELOG.md | 2 + packages/atom-solid/LICENSE | 22 ++ packages/atom-solid/README.md | 4 + packages/atom-solid/docgen.json | 25 ++ packages/atom-solid/package.json | 37 +++ packages/atom-solid/src/Hooks.ts | 201 ++++++++++++++++ packages/atom-solid/src/RegistryContext.ts | 39 +++ packages/atom-solid/src/index.ts | 57 +++++ packages/atom-solid/test/index.test.ts | 264 +++++++++++++++++++++ packages/atom-solid/tsconfig.build.json | 11 + packages/atom-solid/tsconfig.examples.json | 17 ++ packages/atom-solid/tsconfig.json | 15 ++ packages/atom-solid/tsconfig.src.json | 11 + packages/atom-solid/tsconfig.test.json | 15 ++ packages/atom-solid/vitest.config.ts | 17 ++ pnpm-lock.yaml | 39 +++ tsconfig.base.json | 5 +- tsconfig.build.json | 3 + tsconfig.json | 3 + 21 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 packages/atom-solid/.gitignore create mode 100644 packages/atom-solid/.prettierignore create mode 100644 packages/atom-solid/CHANGELOG.md create mode 100644 packages/atom-solid/LICENSE create mode 100644 packages/atom-solid/README.md create mode 100644 packages/atom-solid/docgen.json create mode 100644 packages/atom-solid/package.json create mode 100644 packages/atom-solid/src/Hooks.ts create mode 100644 packages/atom-solid/src/RegistryContext.ts create mode 100644 packages/atom-solid/src/index.ts create mode 100644 packages/atom-solid/test/index.test.ts create mode 100644 packages/atom-solid/tsconfig.build.json create mode 100644 packages/atom-solid/tsconfig.examples.json create mode 100644 packages/atom-solid/tsconfig.json create mode 100644 packages/atom-solid/tsconfig.src.json create mode 100644 packages/atom-solid/tsconfig.test.json create mode 100644 packages/atom-solid/vitest.config.ts diff --git a/packages/atom-solid/.gitignore b/packages/atom-solid/.gitignore new file mode 100644 index 00000000..6140ecf7 --- /dev/null +++ b/packages/atom-solid/.gitignore @@ -0,0 +1,14 @@ +coverage/ +*.tsbuildinfo +node_modules/ +.ultra.cache.json +.DS_Store +tmp/ +build/ +dist/ +.direnv/ + +# files +/src/tsconfig.json +/dist + diff --git a/packages/atom-solid/.prettierignore b/packages/atom-solid/.prettierignore new file mode 100644 index 00000000..df19601d --- /dev/null +++ b/packages/atom-solid/.prettierignore @@ -0,0 +1,3 @@ +*.js +*.ts + diff --git a/packages/atom-solid/CHANGELOG.md b/packages/atom-solid/CHANGELOG.md new file mode 100644 index 00000000..4dc68c6f --- /dev/null +++ b/packages/atom-solid/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/packages/atom-solid/LICENSE b/packages/atom-solid/LICENSE new file mode 100644 index 00000000..c8c4a82d --- /dev/null +++ b/packages/atom-solid/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023-present The Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/atom-solid/README.md b/packages/atom-solid/README.md new file mode 100644 index 00000000..22f23d97 --- /dev/null +++ b/packages/atom-solid/README.md @@ -0,0 +1,4 @@ +# `@effect-atom/atom-solid` + +SolidJS bindings for `@effect-atom/atom`. + diff --git a/packages/atom-solid/docgen.json b/packages/atom-solid/docgen.json new file mode 100644 index 00000000..ef8e5eee --- /dev/null +++ b/packages/atom-solid/docgen.json @@ -0,0 +1,25 @@ +{ + "exclude": ["src/internal/**/*.ts"], + "theme": "mikearnaldi/just-the-docs", + "parseCompilerOptions": { + "noEmit": true, + "strict": true, + "target": "es2015", + "lib": ["es2015"], + "paths": { + "@effect-atom/atom": ["./src/index.ts"], + "@effect-atom/atom/*": ["./src/*"] + } + }, + "examplesCompilerOptions": { + "noEmit": true, + "strict": true, + "target": "es2015", + "lib": ["es2015"], + "paths": { + "@effect-atom/atom": ["./src/index.ts"], + "@effect-atom/atom/*": ["./src/*"] + } + } +} + diff --git a/packages/atom-solid/package.json b/packages/atom-solid/package.json new file mode 100644 index 00000000..491303fd --- /dev/null +++ b/packages/atom-solid/package.json @@ -0,0 +1,37 @@ +{ + "name": "@effect-atom/atom-solid", + "version": "0.4.5", + "description": "Reactive toolkit for Effect", + "type": "module", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/tim-smart/effect-atom.git" + }, + "homepage": "https://github.com/tim-smart/effect-atom", + "scripts": { + "build": "pnpm build-esm && pnpm build-cjs && pnpm build-annotate && build-utils pack-v2", + "build-esm": "tsc -b tsconfig.build.json", + "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "build-annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps" + }, + "keywords": [], + "author": "Effect contributors", + "license": "MIT", + "sideEffects": [], + "devDependencies": { + "effect": "^3.19.0", + "solid-js": "^1.9.0" + }, + "peerDependencies": { + "effect": "^3.19", + "solid-js": ">=1 <2" + }, + "dependencies": { + "@effect-atom/atom": "workspace:^" + } +} + diff --git a/packages/atom-solid/src/Hooks.ts b/packages/atom-solid/src/Hooks.ts new file mode 100644 index 00000000..eeb218c5 --- /dev/null +++ b/packages/atom-solid/src/Hooks.ts @@ -0,0 +1,201 @@ +/** + * @since 1.0.0 + */ +import * as Atom from "@effect-atom/atom/Atom" +import type * as AtomRef from "@effect-atom/atom/AtomRef" +import * as Registry from "@effect-atom/atom/Registry" +import type * as Result from "@effect-atom/atom/Result" +import * as Cause from "effect/Cause" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import { globalValue } from "effect/GlobalValue" +import type { Accessor } from "solid-js" +import { createSignal, onCleanup, useContext } from "solid-js" +import { RegistryContext } from "./RegistryContext.js" + +const initialValuesSet = globalValue( + "@effect-atom/atom-solid/initialValuesSet", + () => new WeakMap>>() +) + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomInitialValues = (initialValues: Iterable, any]>): void => { + const registry = useContext(RegistryContext) + let set = initialValuesSet.get(registry) + if (set === undefined) { + set = new WeakSet() + initialValuesSet.set(registry, set) + } + for (const [atom, value] of initialValues) { + if (!set.has(atom)) { + set.add(atom) + ;(registry as any).ensureNode(atom).setValue(value) + } + } +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomValue: { + (atom: Atom.Atom): Accessor + (atom: Atom.Atom, f: (_: A) => B): Accessor +} = (atom: Atom.Atom, f?: (_: A) => A): Accessor => { + const registry = useContext(RegistryContext) + return createAtomAccessor(registry, f ? Atom.map(atom, f) : atom) +} + +function createAtomAccessor(registry: Registry.Registry, atom: Atom.Atom): Accessor { + const [value, setValue] = createSignal(registry.get(atom)) + onCleanup(registry.subscribe(atom, setValue as any)) + return value +} + +function mountAtom(registry: Registry.Registry, atom: Atom.Atom): void { + onCleanup(registry.mount(atom)) +} + +function setAtom( + registry: Registry.Registry, + atom: Atom.Writable, + options?: { + readonly mode?: ([R] extends [Result.Result] ? Mode : "value") | undefined + } +): "promise" extends Mode ? ( + (value: W) => Promise> + ) : + "promiseExit" extends Mode ? ( + (value: W) => Promise, Result.Result.Failure>> + ) : + ((value: W | ((value: R) => W)) => void) +{ + if (options?.mode === "promise" || options?.mode === "promiseExit") { + return ((value: W) => { + registry.set(atom, value) + const promise = Effect.runPromiseExit( + Registry.getResult(registry, atom as Atom.Atom>, { suspendOnWaiting: true }) + ) + return options!.mode === "promise" ? promise.then(flattenExit) : promise + }) as any + } + return ((value: W | ((value: R) => W)) => { + registry.set(atom, typeof value === "function" ? (value as any)(registry.get(atom)) : value) + }) as any +} + +const flattenExit = (exit: Exit.Exit): A => { + if (Exit.isSuccess(exit)) return exit.value + throw Cause.squash(exit.cause) +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomMount = (atom: Atom.Atom): void => { + const registry = useContext(RegistryContext) + mountAtom(registry, atom) +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomSet = < + R, + W, + Mode extends "value" | "promise" | "promiseExit" = never +>( + atom: Atom.Writable, + options?: { + readonly mode?: ([R] extends [Result.Result] ? Mode : "value") | undefined + } +): "promise" extends Mode ? ( + (value: W) => Promise> + ) : + "promiseExit" extends Mode ? ( + (value: W) => Promise, Result.Result.Failure>> + ) : + ((value: W | ((value: R) => W)) => void) => +{ + const registry = useContext(RegistryContext) + mountAtom(registry, atom) + return setAtom(registry, atom, options) +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomRefresh = (atom: Atom.Atom): () => void => { + const registry = useContext(RegistryContext) + mountAtom(registry, atom) + return () => registry.refresh(atom) +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtom = ( + atom: Atom.Writable, + options?: { + readonly mode?: ([R] extends [Result.Result] ? Mode : "value") | undefined + } +): readonly [ + value: Accessor, + write: "promise" extends Mode ? ( + (value: W) => Promise> + ) : + "promiseExit" extends Mode ? ( + (value: W) => Promise, Result.Result.Failure>> + ) : + ((value: W | ((value: R) => W)) => void) +] => { + const registry = useContext(RegistryContext) + return [ + createAtomAccessor(registry, atom), + setAtom(registry, atom, options) + ] as const +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomSubscribe = ( + atom: Atom.Atom, + f: (_: A) => void, + options?: { readonly immediate?: boolean } +): void => { + const registry = useContext(RegistryContext) + onCleanup(registry.subscribe(atom, f, options)) +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomRef = (ref: AtomRef.ReadonlyRef): Accessor => { + const [value, setValue] = createSignal(ref.value) + onCleanup(ref.subscribe(setValue)) + return value +} + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomRefProp = (ref: AtomRef.AtomRef, prop: K): AtomRef.AtomRef => + ref.prop(prop) + +/** + * @since 1.0.0 + * @category hooks + */ +export const useAtomRefPropValue = (ref: AtomRef.AtomRef, prop: K): Accessor => + useAtomRef(useAtomRefProp(ref, prop)) diff --git a/packages/atom-solid/src/RegistryContext.ts b/packages/atom-solid/src/RegistryContext.ts new file mode 100644 index 00000000..a63ba3de --- /dev/null +++ b/packages/atom-solid/src/RegistryContext.ts @@ -0,0 +1,39 @@ +/** + * @since 1.0.0 + */ +import type * as Atom from "@effect-atom/atom/Atom" +import * as Registry from "@effect-atom/atom/Registry" +import type { JSX } from "solid-js" +import { createComponent, createContext, onCleanup } from "solid-js" + +/** + * @since 1.0.0 + * @category context + */ +export const RegistryContext = createContext(Registry.make()) + +/** + * @since 1.0.0 + * @category context + */ +export const RegistryProvider = (options: { + readonly children?: JSX.Element | undefined + readonly initialValues?: Iterable, any]> | undefined + readonly scheduleTask?: ((f: () => void) => void) | undefined + readonly timeoutResolution?: number | undefined + readonly defaultIdleTTL?: number | undefined +}) => { + const registry = Registry.make({ + scheduleTask: options.scheduleTask, + initialValues: options.initialValues, + timeoutResolution: options.timeoutResolution, + defaultIdleTTL: options.defaultIdleTTL + }) + onCleanup(() => registry.dispose()) + return createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return options.children + } + }) +} diff --git a/packages/atom-solid/src/index.ts b/packages/atom-solid/src/index.ts new file mode 100644 index 00000000..e3ccf76a --- /dev/null +++ b/packages/atom-solid/src/index.ts @@ -0,0 +1,57 @@ +/** + * @since 1.0.0 + */ + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as Atom from "@effect-atom/atom/Atom" + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as Registry from "@effect-atom/atom/Registry" + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as Result from "@effect-atom/atom/Result" + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as AtomRef from "@effect-atom/atom/AtomRef" + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as AtomHttpApi from "@effect-atom/atom/AtomHttpApi" + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as AtomRpc from "@effect-atom/atom/AtomRpc" + +/** + * @since 1.0.0 + * @category re-exports + */ +export * as Hydration from "@effect-atom/atom/Hydration" + +/** + * @since 1.0.0 + * @category hooks + */ +export * from "./Hooks.js" + +/** + * @since 1.0.0 + * @category context + */ +export * from "./RegistryContext.js" diff --git a/packages/atom-solid/test/index.test.ts b/packages/atom-solid/test/index.test.ts new file mode 100644 index 00000000..cc4d1a17 --- /dev/null +++ b/packages/atom-solid/test/index.test.ts @@ -0,0 +1,264 @@ +import * as Atom from "@effect-atom/atom/Atom" +import * as AtomRef from "@effect-atom/atom/AtomRef" +import * as Registry from "@effect-atom/atom/Registry" +import type * as Result from "@effect-atom/atom/Result" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import { createComponent, createRoot } from "solid-js" +import { describe, expect, it, vi } from "vitest" +import { + RegistryContext, + useAtom, + useAtomInitialValues, + useAtomMount, + useAtomRef, + useAtomRefProp, + useAtomRefPropValue, + useAtomRefresh, + useAtomSet, + useAtomSubscribe, + useAtomValue +} from "../src/index.js" + +describe("atom-solid", () => { + it("useAtomValue reads and updates", () => { + const atom = Atom.make(42) + const registry = Registry.make() + + createRoot((dispose) => { + let value!: () => number + + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + value = useAtomValue(atom) + return null as any + }, {}) + } + }) + + expect(value()).toBe(42) + registry.set(atom, 100) + expect(value()).toBe(100) + + dispose() + }) + }) + + it("useAtom returns accessor + setter", () => { + const atom = Atom.make(0) + const registry = Registry.make() + + createRoot((dispose) => { + let value!: () => number + let set!: (value: number | ((n: number) => number)) => void + + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + ;[value, set] = useAtom(atom) + return null as any + }, {}) + } + }) + + expect(value()).toBe(0) + set(1) + expect(value()).toBe(1) + set((n) => n + 1) + expect(value()).toBe(2) + + dispose() + }) + }) + + it("useAtomSet writes values", () => { + const atom = Atom.make(0) + const registry = Registry.make() + + createRoot((dispose) => { + let set!: (value: number | ((n: number) => number)) => void + + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + set = useAtomSet(atom) + return null as any + }, {}) + } + }) + + set(1) + expect(registry.get(atom)).toBe(1) + set((n) => n + 1) + expect(registry.get(atom)).toBe(2) + + dispose() + }) + }) + + it("useAtomSubscribe receives updates (immediate)", () => { + const atom = Atom.make("a") + const registry = Registry.make() + + createRoot((dispose) => { + const spy = vi.fn<(value: string) => void>() + + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + useAtomSubscribe(atom, spy, { immediate: true }) + return null as any + }, {}) + } + }) + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith("a") + + registry.set(atom, "b") + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenLastCalledWith("b") + + dispose() + }) + }) + + it("useAtomRefresh triggers recomputation", () => { + const nowAtom = Atom.make(Effect.sync(() => Date.now())) + const registry = Registry.make() + + createRoot((dispose) => { + let now!: () => number + let refresh!: () => void + + const originalNow = Date.now + let n = 0 + // Deterministic timestamps so we can assert a change. + Date.now = () => ++n + + try { + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + now = useAtomValue(nowAtom, (r) => (r._tag === "Success" ? r.value : -1)) + refresh = useAtomRefresh(nowAtom) + useAtomMount(nowAtom) + return null as any + }, {}) + } + }) + + const first = now() + refresh() + const second = now() + expect(second).not.toBe(first) + } finally { + Date.now = originalNow + dispose() + } + }) + }) + + it("useAtomRef + prop helpers", () => { + const ref = AtomRef.make({ a: 1, b: 2 }) + + createRoot((dispose) => { + let whole!: () => { a: number; b: number } + let aRef!: AtomRef.AtomRef + let aValue!: () => number + + createComponent(() => { + whole = useAtomRef(ref) + aRef = useAtomRefProp(ref, "a") + aValue = useAtomRefPropValue(ref, "a") + return null as any + }, {}) + + expect(whole().a).toBe(1) + expect(aRef.value).toBe(1) + expect(aValue()).toBe(1) + + ref.set({ a: 10, b: 2 }) + expect(whole().a).toBe(10) + expect(aRef.value).toBe(10) + expect(aValue()).toBe(10) + + dispose() + }) + }) + + it("useAtomSet supports Result atoms (promise + promiseExit)", async () => { + const fnAtom = Atom.fn((n: number) => Effect.succeed(n * 2)) + const registry = Registry.make() + + await new Promise((outerResolve, outerReject) => { + createRoot((dispose) => { + let run!: (n: number) => Promise + let runExit!: (n: number) => Promise> + let last!: () => Result.Result + + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + run = useAtomSet(fnAtom, { mode: "promise" as const }) + runExit = useAtomSet(fnAtom, { mode: "promiseExit" as const }) as any + last = useAtomValue(fnAtom) + return null as any + }, {}) + } + }) + + Promise.resolve() + .then(async () => { + expect(last()._tag).toBe("Initial") + + const value = await run(21) + expect(value).toBe(42) + expect(last()._tag).toBe("Success") + + const exit = await runExit(10) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value).toBe(20) + } + }) + .then(() => { + dispose() + outerResolve() + }) + .catch((e) => { + dispose() + outerReject(e) + }) + }) + }) + }) + + it("useAtomInitialValues only applies once per atom per registry", () => { + const atom = Atom.make(0) + const registry = Registry.make() + + createRoot((dispose) => { + createComponent(RegistryContext.Provider, { + value: registry, + get children() { + return createComponent(() => { + useAtomInitialValues([[atom, 1]]) + useAtomInitialValues([[atom, 2]]) + return null as any + }, {}) + } + }) + + expect(registry.get(atom)).toBe(1) + dispose() + }) + }) +}) diff --git a/packages/atom-solid/tsconfig.build.json b/packages/atom-solid/tsconfig.build.json new file mode 100644 index 00000000..e7b7f06b --- /dev/null +++ b/packages/atom-solid/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + }, + "references": [{ "path": "../atom" }] +} + diff --git a/packages/atom-solid/tsconfig.examples.json b/packages/atom-solid/tsconfig.examples.json new file mode 100644 index 00000000..2815f41b --- /dev/null +++ b/packages/atom-solid/tsconfig.examples.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "examples" + ], + "references": [ + { + "path": "tsconfig.build.json" + } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/examples.tsbuildinfo", + "rootDir": "examples", + "noEmit": true + } +} + diff --git a/packages/atom-solid/tsconfig.json b/packages/atom-solid/tsconfig.json new file mode 100644 index 00000000..6591e4ac --- /dev/null +++ b/packages/atom-solid/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { + "path": "tsconfig.src.json" + }, + { + "path": "tsconfig.test.json" + }, + { + "path": "tsconfig.examples.json" + } + ] +} + diff --git a/packages/atom-solid/tsconfig.src.json b/packages/atom-solid/tsconfig.src.json new file mode 100644 index 00000000..4f235a69 --- /dev/null +++ b/packages/atom-solid/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "outDir": "build/src" + }, + "references": [{ "path": "../atom" }] +} + diff --git a/packages/atom-solid/tsconfig.test.json b/packages/atom-solid/tsconfig.test.json new file mode 100644 index 00000000..8c036329 --- /dev/null +++ b/packages/atom-solid/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test"], + "references": [ + { + "path": "tsconfig.src.json" + } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "noEmit": true + } +} + diff --git a/packages/atom-solid/vitest.config.ts b/packages/atom-solid/vitest.config.ts new file mode 100644 index 00000000..a8721330 --- /dev/null +++ b/packages/atom-solid/vitest.config.ts @@ -0,0 +1,17 @@ +import * as path from "path" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["./test/**/*.test.ts"] + }, + resolve: { + alias: { + "@effect-atom/atom/test": path.join(__dirname, "../atom/test"), + "@effect-atom/atom": path.join(__dirname, "../atom/src"), + "@effect-atom/atom-solid/test": path.join(__dirname, "test"), + "@effect-atom/atom-solid": path.join(__dirname, "src") + } + } +}) + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d1eb13..9143eaa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,20 @@ importers: version: 0.27.0 publishDirectory: dist + packages/atom-solid: + dependencies: + '@effect-atom/atom': + specifier: workspace:^ + version: link:../atom/dist + devDependencies: + effect: + specifier: ^3.19.0 + version: 3.19.15 + solid-js: + specifier: ^1.9.0 + version: 1.9.11 + publishDirectory: dist + packages/atom-vue: dependencies: '@effect-atom/atom': @@ -3738,6 +3752,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + engines: {node: '>=10'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3796,6 +3820,9 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8227,6 +8254,12 @@ snapshots: semver@7.7.3: {} + seroval-plugins@1.5.0(seroval@1.5.0): + dependencies: + seroval: 1.5.0 + + seroval@1.5.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8297,6 +8330,12 @@ snapshots: slash@3.0.0: {} + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + source-map-js@1.2.1: {} source-map@0.6.1: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f66daf5..ac77fd69 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,7 +43,10 @@ "@effect-atom/atom-react/*": ["./packages/atom-react/src/*.js"], "@effect-atom/atom-react": ["./packages/atom-react/src/index.js"], "@effect-atom/atom-vue/test/*": ["./packages/atom-vue/test/*.js"], - "@effect-atom/atom-vue/*": ["./packages/atom-vue/src/*.js"] + "@effect-atom/atom-vue/*": ["./packages/atom-vue/src/*.js"], + "@effect-atom/atom-solid/test/*": ["./packages/atom-solid/test/*.js"], + "@effect-atom/atom-solid/*": ["./packages/atom-solid/src/*.js"], + "@effect-atom/atom-solid": ["./packages/atom-solid/src/index.js"] }, "plugins": [{ "name": "@effect/language-service" }] } diff --git a/tsconfig.build.json b/tsconfig.build.json index 2fc0c7eb..623cee5d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -9,6 +9,9 @@ }, { "path": "packages/atom-vue/tsconfig.build.json" + }, + { + "path": "packages/atom-solid/tsconfig.build.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index d2c10675..9bfd519b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,9 @@ }, { "path": "packages/atom-vue" + }, + { + "path": "packages/atom-solid" } ] } From 961874605581e25c74bd4e24d6abe77db8262f21 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 13 Feb 2026 17:17:34 -0500 Subject: [PATCH 2/2] fix: wrap queueMicrotask in RegistryContext to prevent illegal invocation queueMicrotask passed unbound can throw "Illegal invocation" in browser runtimes when Registry invokes it as this.scheduleTask(). Wrap it in an arrow function for both the default context and RegistryProvider. --- packages/atom-solid/src/RegistryContext.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/atom-solid/src/RegistryContext.ts b/packages/atom-solid/src/RegistryContext.ts index a63ba3de..18dc8fd1 100644 --- a/packages/atom-solid/src/RegistryContext.ts +++ b/packages/atom-solid/src/RegistryContext.ts @@ -6,11 +6,15 @@ import * as Registry from "@effect-atom/atom/Registry" import type { JSX } from "solid-js" import { createComponent, createContext, onCleanup } from "solid-js" +const defaultScheduleTask = (f: () => void) => queueMicrotask(f) + /** * @since 1.0.0 * @category context */ -export const RegistryContext = createContext(Registry.make()) +export const RegistryContext = createContext(Registry.make({ + scheduleTask: defaultScheduleTask +})) /** * @since 1.0.0 @@ -24,7 +28,7 @@ export const RegistryProvider = (options: { readonly defaultIdleTTL?: number | undefined }) => { const registry = Registry.make({ - scheduleTask: options.scheduleTask, + scheduleTask: options.scheduleTask ?? defaultScheduleTask, initialValues: options.initialValues, timeoutResolution: options.timeoutResolution, defaultIdleTTL: options.defaultIdleTTL