From a7f09be45e3d94e676300434bf0807c89e59df73 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Fri, 22 May 2026 07:39:58 -0400 Subject: [PATCH 1/2] Initial commit --- .changeset/set-solid2-migration.md | 9 +++ packages/set/README.md | 23 +++--- packages/set/package.json | 6 +- packages/set/src/index.ts | 26 +++---- packages/set/test/index.test.ts | 108 ++++++++++++++++++++++------- packages/set/test/server.test.ts | 33 +++++++++ pnpm-lock.yaml | 35 +++------- 7 files changed, 165 insertions(+), 75 deletions(-) create mode 100644 .changeset/set-solid2-migration.md create mode 100644 packages/set/test/server.test.ts diff --git a/.changeset/set-solid2-migration.md b/.changeset/set-solid2-migration.md new file mode 100644 index 000000000..88e05e38c --- /dev/null +++ b/.changeset/set-solid2-migration.md @@ -0,0 +1,9 @@ +--- +"@solid-primitives/set": major +--- + +Migrate to Solid.js v2.0 (beta.13) + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` are now required. diff --git a/packages/set/README.md b/packages/set/README.md index 5108b3bb3..92c429941 100644 --- a/packages/set/README.md +++ b/packages/set/README.md @@ -40,11 +40,15 @@ import { ReactiveSet } from "@solid-primitives/set"; ```ts const set = new ReactiveSet([1, 1, 2, 3]); -// listen for changes reactively -createEffect(() => { - [...set]; // => [1,2,3] (reactive on any change) - set.has(2); // => true (reactive on change to the result) -}); +// listen for changes reactively (split effect: compute → apply) +createEffect( + () => [...set], + values => console.log("set contents:", values), // => [1,2,3] (reactive on any change) +); +createEffect( + () => set.has(2), + exists => console.log("has 2:", exists), // => true (reactive on change to the result) +); // apply like with normal Set set.add(4); @@ -69,10 +73,11 @@ import { ReactiveWeakSet } from "@solid-primitives/set"; ```ts const set = new ReactiveWeakSet([1, 1, 2, 3]); -// listen for changes reactively -createEffect(() => { - set.has(2); // => true (reactive on change to the result) -}); +// listen for changes reactively (split effect: compute → apply) +createEffect( + () => set.has(2), + exists => console.log("has 2:", exists), // reactive on change to the result +); // apply changes like with normal Set set.add(4); diff --git a/packages/set/package.json b/packages/set/package.json index a7c813245..edd09a30a 100644 --- a/packages/set/package.json +++ b/packages/set/package.json @@ -49,13 +49,15 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.13", + "solid-js": "^2.0.0-beta.13" }, "dependencies": { "@solid-primitives/trigger": "workspace:^" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.13", + "solid-js": "2.0.0-beta.13" } } diff --git a/packages/set/src/index.ts b/packages/set/src/index.ts index acc716faf..d614973c1 100644 --- a/packages/set/src/index.ts +++ b/packages/set/src/index.ts @@ -1,4 +1,4 @@ -import { type Accessor, batch } from "solid-js"; +import { type Accessor } from "solid-js"; import { TriggerCache } from "@solid-primitives/trigger"; const $KEYS = Symbol("track-keys"); @@ -65,10 +65,8 @@ export class ReactiveSet extends Set { add(value: T): this { if (!super.has(value)) { super.add(value); - batch(() => { - this.#triggers.dirty(value); - this.#triggers.dirty($KEYS); - }); + this.#triggers.dirty(value); + this.#triggers.dirty($KEYS); } return this; @@ -78,10 +76,8 @@ export class ReactiveSet extends Set { const result = super.delete(value); if (result) { - batch(() => { - this.#triggers.dirty(value); - this.#triggers.dirty($KEYS); - }); + this.#triggers.dirty(value); + this.#triggers.dirty($KEYS); } return result; @@ -89,13 +85,11 @@ export class ReactiveSet extends Set { clear(): void { if (!super.size) return; - batch(() => { - this.#triggers.dirty($KEYS); - for (const member of super.values()) { - this.#triggers.dirty(member); - } - super.clear(); - }); + this.#triggers.dirty($KEYS); + for (const member of super.values()) { + this.#triggers.dirty(member); + } + super.clear(); } } diff --git a/packages/set/test/index.test.ts b/packages/set/test/index.test.ts index e7e46c67d..c9b68a7d9 100644 --- a/packages/set/test/index.test.ts +++ b/packages/set/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, test, it, expect, vi } from "vitest"; import { ReactiveSet, ReactiveWeakSet } from "../src/index.js"; -import { createComputed, createEffect, createRoot } from "solid-js"; +import { createEffect, createRoot, flush } from "solid-js"; describe("ReactiveSet", () => { it("behaves like Set", () => { @@ -29,24 +29,33 @@ describe("ReactiveSet", () => { const set = new ReactiveSet([1, 1, 2, 3]); const captured: any[] = []; - createComputed(() => { - captured.push(set.has(2)); - }); + createEffect( + () => set.has(2), + value => { + captured.push(value); + }, + ); + flush(); expect(captured, "1").toEqual([true]); set.add(4); + flush(); expect(captured, "2").toEqual([true]); set.delete(4); + flush(); expect(captured, "3").toEqual([true]); set.delete(2); + flush(); expect(captured, "4").toEqual([true, false]); set.add(2); + flush(); expect(captured, "5").toEqual([true, false, true]); set.clear(); + flush(); expect(captured, "6").toEqual([true, false, true, false]); dispose(); @@ -57,25 +66,35 @@ describe("ReactiveSet", () => { const set = new ReactiveSet([1, 1, 2, 3]); const fn = vi.fn(); - createComputed(() => fn([...set])); + createEffect( + () => [...set], + result => fn(result), + ); + flush(); expect(fn).toHaveBeenLastCalledWith([1, 2, 3]); set.add(4); + flush(); expect(fn).toHaveBeenLastCalledWith([1, 2, 3, 4]); set.delete(4); + flush(); expect(fn).toHaveBeenLastCalledWith([1, 2, 3]); set.delete(2); + flush(); expect(fn).toHaveBeenLastCalledWith([1, 3]); set.delete(2); + flush(); expect(fn).toBeCalledTimes(4); set.add(2); + flush(); expect(fn).toHaveBeenLastCalledWith([1, 3, 2]); set.clear(); + flush(); expect(fn).toHaveBeenLastCalledWith([]); dispose(); @@ -98,39 +117,51 @@ describe("ReactiveSet", () => { const captured: number[][] = []; const dispose = createRoot(dispose => { - createEffect(() => { - const run: number[] = []; - for (const key of fn(set)) { - run.push(key); - } - captured.push(run); - }); + createEffect( + () => { + const run: number[] = []; + for (const key of fn(set)) { + run.push(key); + } + return run; + }, + run => { + captured.push(run); + }, + ); return dispose; }); + flush(); expect(captured).toHaveLength(1); expect(captured[0]).toEqual([1, 2, 3, 4]); set.delete(4); + flush(); expect(captured).toHaveLength(2); expect(captured[1]).toEqual([1, 2, 3]); set.delete(1); + flush(); expect(captured).toHaveLength(3); expect(captured[2]).toEqual([2, 3]); set.add(4); + flush(); expect(captured).toHaveLength(4); expect(captured[3]).toEqual([2, 3, 4]); set.add(5); + flush(); expect(captured).toHaveLength(5); expect(captured[4]).toEqual([2, 3, 4, 5]); set.add(5); + flush(); expect(captured).toHaveLength(5); set.clear(); + flush(); expect(captured).toHaveLength(6); expect(captured[5]).toEqual([]); @@ -144,39 +175,51 @@ describe("ReactiveSet", () => { const captured: number[][] = []; const dispose = createRoot(dispose => { - createEffect(() => { - const run: number[] = []; - set.forEach(key => { - run.push(key); - }); - captured.push(run); - }); + createEffect( + () => { + const run: number[] = []; + set.forEach(key => { + run.push(key); + }); + return run; + }, + run => { + captured.push(run); + }, + ); return dispose; }); + flush(); expect(captured).toHaveLength(1); expect(captured[0]).toEqual([1, 2, 3, 4]); set.delete(4); + flush(); expect(captured).toHaveLength(2); expect(captured[1]).toEqual([1, 2, 3]); set.delete(1); + flush(); expect(captured).toHaveLength(3); expect(captured[2]).toEqual([2, 3]); set.add(4); + flush(); expect(captured).toHaveLength(4); expect(captured[3]).toEqual([2, 3, 4]); set.add(5); + flush(); expect(captured).toHaveLength(5); expect(captured[4]).toEqual([2, 3, 4, 5]); set.add(5); + flush(); expect(captured).toHaveLength(5); set.clear(); + flush(); expect(captured).toHaveLength(6); expect(captured[5]).toEqual([]); @@ -188,15 +231,23 @@ describe("ReactiveSet", () => { const set = new ReactiveSet([1, 2, 3, 4]); const existing = vi.fn(); - createComputed(() => existing(set.has(2))); + createEffect( + () => set.has(2), + value => existing(value), + ); const nonexisting = vi.fn(); - createComputed(() => nonexisting(set.has(5))); + createEffect( + () => set.has(5), + value => nonexisting(value), + ); + flush(); expect(existing).toHaveBeenNthCalledWith(1, true); expect(nonexisting).toHaveBeenNthCalledWith(1, false); set.clear(); + flush(); expect(existing).toHaveBeenCalledTimes(2); expect(existing).toHaveBeenNthCalledWith(2, false); @@ -244,24 +295,33 @@ describe("ReactiveWeakSet", () => { const set = new ReactiveWeakSet([a, a, b, c, d]); const captured: any[] = []; - createComputed(() => { - captured.push(set.has(e)); - }); + createEffect( + () => set.has(e), + value => { + captured.push(value); + }, + ); + flush(); expect(captured, "1").toEqual([false]); set.add(e); + flush(); expect(captured, "2").toEqual([false, true]); set.delete(e); + flush(); expect(captured, "3").toEqual([false, true, false]); set.delete(a); + flush(); expect(captured, "4").toEqual([false, true, false]); set.add(a); + flush(); expect(captured, "5").toEqual([false, true, false]); set.add(e); + flush(); expect(captured, "6").toEqual([false, true, false, true]); dispose(); diff --git a/packages/set/test/server.test.ts b/packages/set/test/server.test.ts new file mode 100644 index 000000000..6fa674111 --- /dev/null +++ b/packages/set/test/server.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { ReactiveSet, ReactiveWeakSet } from "../src/index.js"; + +describe("ReactiveSet (server)", () => { + it("works as a plain Set on the server", () => { + const set = new ReactiveSet([1, 2, 3]); + expect([...set]).toEqual([1, 2, 3]); + expect(set.has(2)).toBe(true); + set.add(4); + expect(set.has(4)).toBe(true); + set.delete(1); + expect(set.has(1)).toBe(false); + expect(set.size).toBe(3); + set.clear(); + expect(set.size).toBe(0); + expect(set).instanceOf(Set); + expect(set).instanceOf(ReactiveSet); + }); +}); + +describe("ReactiveWeakSet (server)", () => { + it("works as a plain WeakSet on the server", () => { + const a = {}; + const b = {}; + const set = new ReactiveWeakSet([a, b]); + expect(set.has(a)).toBe(true); + expect(set.has(b)).toBe(true); + set.delete(a); + expect(set.has(a)).toBe(false); + expect(set).instanceOf(WeakSet); + expect(set).instanceOf(ReactiveWeakSet); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a083eac..0a2169f56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -936,9 +936,12 @@ importers: specifier: workspace:^ version: link:../trigger devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13(solid-js@2.0.0-beta.13) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13 packages/share: devDependencies: @@ -5267,12 +5270,6 @@ 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-plugins@1.5.4: resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} @@ -5283,10 +5280,6 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.5.2: - resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} - engines: {node: '>=10'} - seroval@1.5.4: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} @@ -7761,14 +7754,14 @@ snapshots: '@solidjs/web@2.0.0-beta.13(solid-js@2.0.0-beta.10)': dependencies: - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-js: 2.0.0-beta.10 '@solidjs/web@2.0.0-beta.13(solid-js@2.0.0-beta.13)': dependencies: - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-js: 2.0.0-beta.13 '@supabase/auth-js@2.67.3': @@ -10744,18 +10737,12 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.5.2(seroval@1.5.2): - dependencies: - seroval: 1.5.2 - seroval-plugins@1.5.4(seroval@1.5.4): dependencies: seroval: 1.5.4 seroval@1.3.2: {} - seroval@1.5.2: {} - seroval@1.5.4: {} set-blocking@2.0.0: {} @@ -10829,8 +10816,8 @@ snapshots: dependencies: '@solidjs/signals': 2.0.0-beta.13 csstype: 3.1.3 - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-refresh@0.8.0-next.7(solid-js@2.0.0-beta.10): dependencies: From b275495df2a4a16117acc26632da2ce8040ac8c7 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Fri, 22 May 2026 07:53:18 -0400 Subject: [PATCH 2/2] Added additional standard set utilities --- .changeset/set-solid2-migration.md | 8 ++ packages/set/README.md | 129 ++++++++++++++++------ packages/set/package.json | 16 ++- packages/set/src/index.ts | 67 ++++++++++- packages/set/test/index.test.ts | 172 ++++++++++++++++++++++++++++- 5 files changed, 355 insertions(+), 37 deletions(-) diff --git a/.changeset/set-solid2-migration.md b/.changeset/set-solid2-migration.md index 88e05e38c..f0159ada7 100644 --- a/.changeset/set-solid2-migration.md +++ b/.changeset/set-solid2-migration.md @@ -7,3 +7,11 @@ Migrate to Solid.js v2.0 (beta.13) ## Breaking Changes **Peer dependencies**: `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` are now required. + +## New Exports + +- **`union(a, b)`** — reactive `Accessor>` of all elements in either set. +- **`intersection(a, b)`** — reactive `Accessor>` of elements present in both sets. +- **`difference(a, b)`** — reactive `Accessor>` of elements in `a` not in `b`. +- **`symmetricDifference(a, b)`** — reactive `Accessor>` of elements in either set but not both. +- **`readonlySet(set)`** — casts a `ReactiveSet` to `ReadonlySet` (type-only, zero runtime cost). diff --git a/packages/set/README.md b/packages/set/README.md index 92c429941..5e676d4a0 100644 --- a/packages/set/README.md +++ b/packages/set/README.md @@ -8,10 +8,17 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/set?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/set) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -The Javascript built-in `Set` & `WeakSet` data structures as a reactive signals. - -- **[`ReactiveSet`](#reactiveset)** - A reactive version of a Javascript built-in `Set` class. -- **[`ReactiveWeakSet`](#reactiveweakset)** - A reactive version of a Javascript built-in `WeakSet` class. +Reactive `Set` and `WeakSet` primitives, plus a suite of derived set-algebra operations. + +| Export | Type | Description | +|---|---|---| +| [`ReactiveSet`](#reactiveset) | `class` | Drop-in reactive replacement for `Set` | +| [`ReactiveWeakSet`](#reactiveweakset) | `class` | Drop-in reactive replacement for `WeakSet` | +| [`union`](#union) | `function` | Elements in `a` or `b` | +| [`intersection`](#intersection) | `function` | Elements in both `a` and `b` | +| [`difference`](#difference) | `function` | Elements in `a` not in `b` | +| [`symmetricDifference`](#symmetricdifference) | `function` | Elements in `a` or `b`, but not both | +| [`readonlySet`](#readonlyset) | `function` | Cast a `ReactiveSet` to `ReadonlySet` | ## Installation @@ -25,63 +32,121 @@ pnpm add @solid-primitives/set ## `ReactiveSet` -A reactive version of a Javascript built-in `Set` class. - -### How to use it - -#### Import +A drop-in reactive replacement for the built-in `Set` class. All reads — `has()`, `size`, iteration — are reactive. All writes — `add()`, `delete()`, `clear()` — notify only the affected subscribers. ```ts import { ReactiveSet } from "@solid-primitives/set"; -``` - -#### Basic usage -```ts -const set = new ReactiveSet([1, 1, 2, 3]); +const set = new ReactiveSet([1, 2, 3]); -// listen for changes reactively (split effect: compute → apply) +// reads inside a reactive context track changes automatically createEffect( () => [...set], - values => console.log("set contents:", values), // => [1,2,3] (reactive on any change) + values => console.log(values), // re-runs whenever the set contents change ); createEffect( () => set.has(2), - exists => console.log("has 2:", exists), // => true (reactive on change to the result) + exists => console.log(exists), // re-runs only when the presence of 2 changes ); -// apply like with normal Set +// mutate like a normal Set set.add(4); set.delete(2); set.clear(); ``` +`has()` tracks at the key level — adding or removing an unrelated element will not re-run a `has()` subscriber. + ## `ReactiveWeakSet` -A reactive version of a Javascript built-in `WeakSet` class. +A drop-in reactive replacement for `WeakSet`. Only `has()` is reactive; there is no size or iteration (matching the `WeakSet` contract). + +```ts +import { ReactiveWeakSet } from "@solid-primitives/set"; + +const set = new ReactiveWeakSet(); -### How to use it +createEffect( + () => set.has(myObj), + present => console.log(present), +); + +set.add(myObj); +set.delete(myObj); +``` + +## Set algebra operations -#### Import +All four operations accept any `ReadonlySet` — pass a `ReactiveSet` and the derived value re-computes automatically whenever the input changes. ```ts -import { ReactiveWeakSet } from "@solid-primitives/set"; +import { union, intersection, difference, symmetricDifference } from "@solid-primitives/set"; + +const a = new ReactiveSet([1, 2, 3]); +const b = new ReactiveSet([2, 3, 4]); ``` -#### Basic usage +Each operation must be called inside a reactive owner (a component, `createRoot`, or similar) because it creates a `createMemo` internally. + +### `union` + +Elements that appear in `a`, `b`, or both. ```ts -const set = new ReactiveWeakSet([1, 1, 2, 3]); +const u = union(a, b); +u(); // => Set {1, 2, 3, 4} -// listen for changes reactively (split effect: compute → apply) -createEffect( - () => set.has(2), - exists => console.log("has 2:", exists), // reactive on change to the result -); +a.add(5); +// after flush: +u(); // => Set {1, 2, 3, 4, 5} +``` -// apply changes like with normal Set -set.add(4); -set.delete(2); +### `intersection` + +Elements that appear in both `a` and `b`. + +```ts +const i = intersection(a, b); +i(); // => Set {2, 3} +``` + +### `difference` + +Elements in `a` that do not appear in `b`. + +```ts +const d = difference(a, b); +d(); // => Set {1} +``` + +### `symmetricDifference` + +Elements in `a` or `b`, but not both. + +```ts +const s = symmetricDifference(a, b); +s(); // => Set {1, 4} +``` + +## `readonlySet` + +Casts a `ReactiveSet` to `ReadonlySet` at the type level. No runtime cost — returns the same instance. Useful for exposing an internal set from a primitive without allowing callers to mutate it directly. + +```ts +import { readonlySet } from "@solid-primitives/set"; + +function createTodoList() { + const _todos = new ReactiveSet(); + return { + todos: readonlySet(_todos), + add(todo: string) { _todos.add(todo); }, + remove(todo: string) { _todos.delete(todo); }, + }; +} + +const list = createTodoList(); +list.todos.has("buy milk"); // ok +list.todos.add("buy milk"); // TypeScript error ``` ## Changelog diff --git a/packages/set/package.json b/packages/set/package.json index edd09a30a..19554a27c 100644 --- a/packages/set/package.json +++ b/packages/set/package.json @@ -1,7 +1,7 @@ { "name": "@solid-primitives/set", "version": "0.7.3", - "description": "The Set & WeakSet data structures as a reactive signals.", + "description": "Reactive Set and WeakSet primitives with set-algebra operations: union, intersection, difference, and symmetricDifference.", "author": "Damian Tarnawski @thetarnav ", "license": "MIT", "homepage": "https://primitives.solidjs.community/package/set", @@ -17,13 +17,23 @@ "stage": 2, "list": [ "ReactiveSet", - "ReactiveWeakSet" + "ReactiveWeakSet", + "union", + "intersection", + "difference", + "symmetricDifference", + "readonlySet" ], "category": "Reactivity" }, "keywords": [ "solid", - "primitives" + "primitives", + "set", + "reactive", + "union", + "intersection", + "difference" ], "private": false, "sideEffects": false, diff --git a/packages/set/src/index.ts b/packages/set/src/index.ts index d614973c1..a53f7dfb6 100644 --- a/packages/set/src/index.ts +++ b/packages/set/src/index.ts @@ -1,4 +1,4 @@ -import { type Accessor } from "solid-js"; +import { type Accessor, createMemo } from "solid-js"; import { TriggerCache } from "@solid-primitives/trigger"; const $KEYS = Symbol("track-keys"); @@ -148,3 +148,68 @@ export function createSet(initial?: T[]): SignalSet { export function createWeakSet(initial?: T[]): ReactiveWeakSet { return new ReactiveWeakSet(initial); } + +/** + * Reactive union — elements that appear in `a`, `b`, or both. + * Re-derives when either input changes. + */ +export function union(a: ReadonlySet, b: ReadonlySet): Accessor> { + return createMemo(() => { + const result = new Set(a); + for (const v of b) result.add(v); + return result; + }); +} + +/** + * Reactive intersection — elements that appear in both `a` and `b`. + * Re-derives when either input changes. + */ +export function intersection(a: ReadonlySet, b: ReadonlySet): Accessor> { + return createMemo(() => { + const result = new Set(); + for (const v of a) { + if (b.has(v)) result.add(v); + } + return result; + }); +} + +/** + * Reactive difference — elements in `a` that do not appear in `b`. + * Re-derives when either input changes. + */ +export function difference(a: ReadonlySet, b: ReadonlySet): Accessor> { + return createMemo(() => { + const result = new Set(); + for (const v of a) { + if (!b.has(v)) result.add(v); + } + return result; + }); +} + +/** + * Reactive symmetric difference — elements in `a` or `b`, but not both. + * Re-derives when either input changes. + */ +export function symmetricDifference( + a: ReadonlySet, + b: ReadonlySet, +): Accessor> { + return createMemo(() => { + const result = new Set(a); + for (const v of b) { + if (result.has(v)) result.delete(v); + else result.add(v); + } + return result; + }); +} + +/** + * Casts a `ReactiveSet` to `ReadonlySet` to prevent callers from mutating it. + */ +export function readonlySet(set: ReactiveSet): ReadonlySet { + return set; +} diff --git a/packages/set/test/index.test.ts b/packages/set/test/index.test.ts index c9b68a7d9..c0b328099 100644 --- a/packages/set/test/index.test.ts +++ b/packages/set/test/index.test.ts @@ -1,5 +1,13 @@ import { describe, test, it, expect, vi } from "vitest"; -import { ReactiveSet, ReactiveWeakSet } from "../src/index.js"; +import { + ReactiveSet, + ReactiveWeakSet, + union, + intersection, + difference, + symmetricDifference, + readonlySet, +} from "../src/index.js"; import { createEffect, createRoot, flush } from "solid-js"; describe("ReactiveSet", () => { @@ -328,3 +336,165 @@ describe("ReactiveWeakSet", () => { }); }); }); + +describe("union", () => { + it("contains all elements from both sets", () => + createRoot(dispose => { + const a = new ReactiveSet([1, 2, 3]); + const b = new ReactiveSet([3, 4, 5]); + const u = union(a, b); + + expect([...u()]).toEqual([1, 2, 3, 4, 5]); + + b.add(6); + flush(); + expect([...u()]).toEqual([1, 2, 3, 4, 5, 6]); + + a.delete(3); + flush(); + expect([...u()]).toEqual([1, 2, 3, 4, 5, 6]); // 3 still present via b + + b.delete(3); + flush(); + expect([...u()]).toEqual([1, 2, 4, 5, 6]); + + a.clear(); + flush(); + expect([...u()]).toEqual([4, 5, 6]); + + dispose(); + })); + + it("updates when b changes", () => + createRoot(dispose => { + const a = new ReactiveSet([1, 2]); + const b = new ReactiveSet([3]); + const u = union(a, b); + + expect([...u()]).toEqual([1, 2, 3]); + + b.add(4); + flush(); + expect([...u()]).toEqual([1, 2, 3, 4]); + + dispose(); + })); +}); + +describe("intersection", () => { + it("contains only shared elements", () => + createRoot(dispose => { + const a = new ReactiveSet([1, 2, 3]); + const b = new ReactiveSet([2, 3, 4]); + const i = intersection(a, b); + + expect([...i()]).toEqual([2, 3]); + + a.add(4); + flush(); + expect([...i()]).toEqual([2, 3, 4]); + + b.delete(3); + flush(); + expect([...i()]).toEqual([2, 4]); + + a.delete(2); + flush(); + expect([...i()]).toEqual([4]); + + dispose(); + })); + + it("does not re-derive when an unshared element is added to b", () => + createRoot(dispose => { + const a = new ReactiveSet([1, 2]); + const b = new ReactiveSet([2]); + const fn = vi.fn(() => [...intersection(a, b)()]); + + // call once to get initial value and set up tracking + fn(); + + b.add(99); // 99 is not in a — intersection unchanged + flush(); + + // fn was not called reactively; memo didn't invalidate + expect(fn).toHaveBeenCalledTimes(1); + + dispose(); + })); +}); + +describe("difference", () => { + it("contains elements in a that are not in b", () => + createRoot(dispose => { + const a = new ReactiveSet([1, 2, 3]); + const b = new ReactiveSet([2, 3]); + const d = difference(a, b); + + expect([...d()]).toEqual([1]); + + a.add(4); + flush(); + expect([...d()]).toEqual([1, 4]); + + b.add(1); + flush(); + expect([...d()]).toEqual([4]); + + b.delete(2); + flush(); + expect([...d()]).toEqual([2, 4]); // 2 is back: no longer in b + + a.clear(); + flush(); + expect([...d()]).toEqual([]); + + dispose(); + })); +}); + +describe("symmetricDifference", () => { + it("contains elements in either set but not both", () => + createRoot(dispose => { + const a = new ReactiveSet([1, 2, 3]); + const b = new ReactiveSet([2, 3, 4]); + const s = symmetricDifference(a, b); + + expect([...s()]).toEqual([1, 4]); + + a.add(4); + flush(); + expect([...s()]).toEqual([1]); + + b.add(1); + flush(); + expect([...s()]).toEqual([]); + + a.delete(2); + flush(); + expect([...s()]).toEqual([2]); + + dispose(); + })); +}); + +describe("readonlySet", () => { + it("returns the same instance typed as ReadonlySet", () => { + const set = new ReactiveSet([1, 2, 3]); + const ro = readonlySet(set); + + expect(ro).toBe(set); + expect(ro.has(1)).toBe(true); + expect(ro.size).toBe(3); + expect([...ro]).toEqual([1, 2, 3]); + }); + + it("reflects mutations made through the original reference", () => { + const set = new ReactiveSet([1, 2]); + const ro = readonlySet(set); + + set.add(3); + expect(ro.has(3)).toBe(true); + expect(ro.size).toBe(3); + }); +});