diff --git a/.changeset/match-solid2-migration.md b/.changeset/match-solid2-migration.md new file mode 100644 index 000000000..074775305 --- /dev/null +++ b/.changeset/match-solid2-migration.md @@ -0,0 +1,11 @@ +--- +"@solid-primitives/match": 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. + +- `JSX` types are now sourced from `@solidjs/web` per Solid 2.0 conventions diff --git a/.storybook/main.ts b/.storybook/main.ts index 0453258d1..2365ccda0 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -49,6 +49,7 @@ const config: StorybookConfig = { resolve: { conditions: ["@solid-primitives/source"], alias: [{ find: "solid-js/web", replacement: "@solidjs/web" }], + dedupe: ["react", "react-dom"], }, }); }, diff --git a/packages/match/CHANGELOG.md b/packages/match/CHANGELOG.md new file mode 100644 index 000000000..9712b0c3a --- /dev/null +++ b/packages/match/CHANGELOG.md @@ -0,0 +1,12 @@ +# @solid-primitives/match + +## 0.1.0 + +### Major Changes + +Migrate to Solid.js v2.0 (beta.13) + +**Peer dependencies**: `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` are now required. + +- `JSX` types are now sourced from `@solidjs/web` per Solid 2.0 conventions +- Signal writes are batched by default in Solid 2.0 β€” call `flush()` after signal writes in tests before reading reactive values diff --git a/packages/match/README.md b/packages/match/README.md index 401a295d6..6a22b8991 100644 --- a/packages/match/README.md +++ b/packages/match/README.md @@ -83,6 +83,8 @@ Use the `partial` prop to only handle some of the union members: /> ``` +> **Note:** `partial` is a TypeScript-only escape hatch β€” it switches the `case` mapped type from required to optional keys. It has no runtime effect; unmatched values fall through to `fallback` regardless of whether `partial` is set. + ### Fallback Provide a fallback element when no match is found or the value is `null`/`undefined`: @@ -133,6 +135,8 @@ Use the `partial` prop to only handle some of the union members: /> ``` +> **Note:** `partial` is a TypeScript-only escape hatch β€” it has no runtime effect. See [`MatchTag` partial matching](#partial-matching) for details. + ### Fallback Provide a fallback element when no match is found or the value is `null`/`undefined`: @@ -148,9 +152,13 @@ Provide a fallback element when no match is found or the value is `null`/`undefi /> ``` +## `MatchField` (deprecated) + +`MatchField` is an alias for `MatchTag` kept for backwards compatibility. Use `MatchTag` in new code. + ## Demo -[Deployed example](https://primitives.solidjs.community/playground/match) | [Source code](https://github.com/solidjs-community/solid-primitives/tree/main/packages/match/dev) +[Storybook](https://primitives.solidjs.community/storybook/?path=/docs/control-flow-match--docs) ## Changelog diff --git a/packages/match/dev/index.tsx b/packages/match/dev/index.tsx deleted file mode 100644 index 5e23ff375..000000000 --- a/packages/match/dev/index.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { type Component, createSignal } from "solid-js"; -import { MatchTag, MatchValue } from "../src/index.js"; - -type AnimalDog = { type: "dog"; breed: string }; -type AnimalCat = { type: "cat"; lives: number }; -type AnimalBird = { type: "bird"; canFly: boolean }; - -type Animal = AnimalDog | AnimalCat | AnimalBird; - -const DogDisplay: Component<{ animal: AnimalDog }> = props => ( -
-
πŸ•
-
Breed: {props.animal.breed}
-
-); - -const CatDisplay: Component<{ animal: AnimalCat }> = props => ( -
-
🐱
-
Lives: {props.animal.lives}
-
-); - -const BirdDisplay: Component<{ animal: AnimalBird }> = props => ( -
-
🐦
-
{props.animal.canFly ? "Can fly" : "Cannot fly"}
-
-); - -const FallbackDisplay: Component = () => ( -
-
❓
-
Fallback content
-
-); - -const App: Component = () => { - const [animal, setAnimal] = createSignal(null); - - const animals: (Animal | null)[] = [ - null, - { type: "dog", breed: "Golden Retriever" }, - { type: "cat", lives: 9 }, - { type: "bird", canFly: true }, - ]; - - return ( -
-
-
-

Match Component Demo

-

Control-flow component for matching discriminated union members

-
- -
- - -
- -
-
-

Complete Match

-

Handles all union members with fallback

-
- , - cat: v => , - bird: v => , - }} - fallback={} - /> -
-
- -
-

Partial Match

-

Only handles dogs and cats

-
- , - cat: v => , - }} - fallback={} - /> -
-
-
- -
-

Value Match

-

Match on union literals

-
-

πŸ•

, - cat: () =>

🐱

, - bird: () =>

🐦

, - }} - fallback={} - /> -
-
-
-
- ); -}; - -export default App; diff --git a/packages/match/package.json b/packages/match/package.json index bbf96afbb..2d1304f38 100644 --- a/packages/match/package.json +++ b/packages/match/package.json @@ -53,9 +53,11 @@ "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" }, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.13", + "solid-js": "2.0.0-beta.13" } } diff --git a/packages/match/src/index.ts b/packages/match/src/index.ts index d0304be45..e339da44c 100644 --- a/packages/match/src/index.ts +++ b/packages/match/src/index.ts @@ -1,4 +1,5 @@ -import { type Accessor, type JSX, createMemo } from "solid-js"; +import { type Accessor, createMemo } from "solid-js"; +import type { JSX } from "@solidjs/web"; /** * Control-flow component for matching discriminated union (tagged union) members. @@ -15,42 +16,53 @@ import { type Accessor, type JSX, createMemo } from "solid-js"; * * const [value, setValue] = createSignal({type: 'foo', foo: 'foo-value'}) * - * <>{v().foo}, * bar: v => <>{v().bar}, * }} /> * ``` */ -export function MatchTag(props: { +// Tag values are constrained to string | number rather than PropertyKey because symbol keys +// cannot be expressed in inline object literals, making symbol-tagged unions impossible to +// match in practice. The narrower constraint surfaces the error at the tag field rather than +// inside the mapped type where it is harder to diagnose. +export function MatchTag(props: { on: T | null | undefined; tag: Tag; case: { [K in T[Tag]]: (v: Accessor) => JSX.Element }; fallback?: JSX.Element; + /** Type-only β€” no runtime effect. Relaxes `case` to allow partial union coverage. */ partial?: false; }): JSX.Element; -export function MatchTag(props: { +export function MatchTag(props: { on: T | null | undefined; case: { [K in T["type"]]: (v: Accessor) => JSX.Element }; fallback?: JSX.Element; + /** Type-only β€” no runtime effect. Relaxes `case` to allow partial union coverage. */ partial?: false; }): JSX.Element; -export function MatchTag(props: { +export function MatchTag(props: { on: T | null | undefined; tag: Tag; case: { [K in T[Tag]]?: (v: Accessor) => JSX.Element }; fallback?: JSX.Element; + /** Type-only β€” no runtime effect. Relaxes `case` to allow partial union coverage. */ partial: true; }): JSX.Element; -export function MatchTag(props: { +export function MatchTag(props: { on: T | null | undefined; case: { [K in T["type"]]?: (v: Accessor) => JSX.Element }; fallback?: JSX.Element; + /** Type-only β€” no runtime effect. Relaxes `case` to allow partial union coverage. */ partial: true; }): JSX.Element; export function MatchTag(props: any): any { + const value = () => props.on; const kind = createMemo(() => props.on?.[props.tag ?? "type"]); - return createMemo(() => props.case[kind()]?.(() => props.on) ?? props.fallback); + return createMemo(() => props.case[kind()]?.(value) ?? props.fallback); } + +/** @deprecated Use {@link MatchTag} instead. */ export { MatchTag as MatchField }; /** @@ -62,22 +74,24 @@ export { MatchTag as MatchField }; * * const [value, setValue] = createSignal('foo') * - *

foo

, * bar: () =>

bar

, * }} /> * ``` */ -export function MatchValue(props: { +export function MatchValue(props: { on: T | null | undefined; case: { [K in T]: (v: K) => JSX.Element }; fallback?: JSX.Element; + /** Type-only β€” no runtime effect. Relaxes `case` to allow partial union coverage. */ partial?: false; }): JSX.Element; -export function MatchValue(props: { +export function MatchValue(props: { on: T | null | undefined; case: { [K in T]?: (v: K) => JSX.Element }; fallback?: JSX.Element; + /** Type-only β€” no runtime effect. Relaxes `case` to allow partial union coverage. */ partial: true; }): JSX.Element; export function MatchValue(props: any): any { diff --git a/packages/match/stories/match.stories.tsx b/packages/match/stories/match.stories.tsx new file mode 100644 index 000000000..69d0d85c2 --- /dev/null +++ b/packages/match/stories/match.stories.tsx @@ -0,0 +1,439 @@ +import { createSignal, For } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { MatchTag, MatchValue } from "@solid-primitives/match"; +import readme from "../README.md?raw"; +import { + Button, + ButtonRow, + Container, + Card, + Section, + StatRow, + BoolRow, + Badge, + colors, + font, + radii, +} from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Control Flow/Match", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +// ── Story 1: Discriminated union by type field ──────────────────────────────── + +type Shape = + | { type: "circle"; radius: number } + | { type: "rect"; width: number; height: number } + | { type: "triangle"; base: number; height: number }; + +const SHAPES: Shape[] = [ + { type: "circle", radius: 40 }, + { type: "rect", width: 80, height: 50 }, + { type: "triangle", base: 70, height: 60 }, +]; + +export const DiscriminatedUnion = meta.story({ + name: "Discriminated union (default tag)", + parameters: { + docs: { + description: { + story: + "`` dispatches on the `type` field by default. Each branch receives a narrowed **accessor** β€” reading `v().radius` is only possible inside the `circle` branch.", + }, + }, + }, + render: () => { + const [shape, setShape] = createSignal(null); + + const area = () => { + const s = shape(); + if (!s) return null; + if (s.type === "circle") return (Math.PI * s.radius ** 2).toFixed(1); + if (s.type === "rect") return (s.width * s.height).toFixed(1); + return ((0.5 * s.base * s.height)).toFixed(1); + }; + + return ( + +

Shape inspector

+ + {/* Shape selector */} +
+ + + {s => ( + + )} + +
+ + {/* MatchTag output */} + + ( +
+
Circle
+
+ radius: {v().radius}px +
+
+ ), + rect: v => ( +
+
Rectangle
+
+ {v().width} Γ— {v().height}px +
+
+ ), + triangle: v => ( +
+
Triangle
+
+ base {v().base}px Β· height {v().height}px +
+
+ ), + }} + fallback={ + + No shape selected + + } + /> +
+ +
+ + +
+ +

+ Each branch accessor is narrowed β€” v().radius only exists inside{" "} + circle. +

+
+ ); + }, +}); + +// ── Story 2: Custom tag field ───────────────────────────────────────────────── + +type Notification = + | { kind: "info"; message: string } + | { kind: "warning"; message: string; code: number } + | { kind: "error"; message: string; code: number; fatal: boolean }; + +const NOTIFS: Notification[] = [ + { kind: "info", message: "Deployment succeeded." }, + { kind: "warning", message: "Disk usage above 80%.", code: 1001 }, + { kind: "error", message: "Out of memory.", code: 5002, fatal: true }, +]; + +const BADGE_VARIANT = { + info: "info", + warning: "warning", + error: "error", +} as const; + +export const CustomTagField = meta.story({ + name: "Custom tag field", + parameters: { + docs: { + description: { + story: + "Pass `tag=\"kind\"` when the discriminant field isn't named `type`. The `case` keys and accessor shape update accordingly.", + }, + }, + }, + render: () => { + const [notif, setNotif] = createSignal(null); + + return ( + +

Notification viewer

+ +
+ + + {n => ( + + )} + +
+ + + ( +
+ info + {v().message} +
+ ), + warning: v => ( +
+ warning #{v().code} + {v().message} +
+ ), + error: v => ( +
+ error #{v().code} + {v().message} + +
+ ), + }} + fallback={ + + No notification + + } + /> +
+ +

+ The discriminant is kind, set via tag="kind". +

+
+ ); + }, +}); + +// ── Story 3: Partial matching with fallback ─────────────────────────────────── + +type MediaEvent = + | { type: "play" } + | { type: "pause" } + | { type: "seek"; position: number } + | { type: "end" }; + +const ALL_EVENTS: MediaEvent[] = [ + { type: "play" }, + { type: "pause" }, + { type: "seek", position: 42 }, + { type: "end" }, +]; + +export const PartialMatch = meta.story({ + name: "Partial match with fallback", + parameters: { + docs: { + description: { + story: + "Add `partial` when you only handle a subset of union members. Unhandled cases fall through to `fallback` β€” identical runtime behavior, but the TypeScript type of `case` switches from required to optional keys.", + }, + }, + }, + render: () => { + const [evt, setEvt] = createSignal(null); + + return ( + +

Media event handler

+

+ Only play and pause are handled; others fall through. +

+ +
+ + + {e => ( + + )} + +
+ + + ( +
+ β–Ά + Playing +
+ ), + pause: () => ( +
+ ⏸ + Paused +
+ ), + }} + fallback={ + + {evt() ? `Unhandled: "${evt()!.type}"` : "No event"} + + } + /> +
+ +

+ partial is TypeScript-only β€” runtime behavior is unchanged. +

+
+ ); + }, +}); + +// ── Story 4: MatchValue on union literals ───────────────────────────────────── + +type Status = "idle" | "loading" | "success" | "error"; + +const STATUSES: Status[] = ["idle", "loading", "success", "error"]; + +const STATUS_COLOR: Record = { + idle: colors.muted, + loading: "#6366f1", + success: colors.success, + error: "#ef4444", +}; + +const STATUS_LABEL: Record = { + idle: "Idle", + loading: "Loading…", + success: "Done", + error: "Failed", +}; + +export const LiteralUnion = meta.story({ + name: "Literal union (MatchValue)", + parameters: { + docs: { + description: { + story: + "`` matches plain string/number literals β€” no tag field required. Each branch receives the matched value directly.", + }, + }, + }, + render: () => { + const [status, setStatus] = createSignal("idle"); + + return ( + +

Request status

+ + {/* Status indicator */} +
+
+ ( + Waiting + ), + loading: () => ( + Fetching data… + ), + success: () => ( + + Data loaded successfully + + ), + error: () => ( + + Request failed + + ), + }} + /> +
+ +
+
+ + {s => ( + + )} + +
+
+ +

+ No object needed β€” MatchValue matches "idle" | "loading" | …{" "} + directly. +

+ + ); + }, +}); diff --git a/packages/match/test/index.test.tsx b/packages/match/test/index.test.tsx index 19f0fbee5..79d336a0b 100644 --- a/packages/match/test/index.test.tsx +++ b/packages/match/test/index.test.tsx @@ -36,9 +36,11 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(undefined); setValue({ type: "foo", foo: "foo-value" }); + s.flush(); v.expect(data.result()).toEqual(<>foo-value); setValue({ type: "bar", bar: "bar-value" }); + s.flush(); v.expect(data.result()).toEqual(<>bar-value); data.dispose(); @@ -78,9 +80,11 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(undefined); setValue({ kind: "foo", foo: "foo-value" }); + s.flush(); v.expect(data.result()).toEqual(<>foo-value); setValue({ kind: "bar", bar: "bar-value" }); + s.flush(); v.expect(data.result()).toEqual(<>bar-value); data.dispose(); @@ -119,9 +123,11 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(undefined); setValue({ type: "foo", foo: "foo-value" }); + s.flush(); v.expect(data.result()).toEqual(<>foo-value); setValue({ type: "bar", bar: "bar-value" }); + s.flush(); v.expect(data.result()).toEqual(undefined); data.dispose(); @@ -161,9 +167,11 @@ v.describe("Match", () => { v.expect(data.result()).toEqual(<>fallback); setValue({ type: "foo", foo: "foo-value" }); + s.flush(); v.expect(data.result()).toEqual(<>foo-value); setValue(undefined); + s.flush(); v.expect(data.result()).toEqual(<>fallback); data.dispose(); @@ -189,13 +197,17 @@ v.describe("MatchValue", () => { )), })); v.expect(data.result()).toEqual(undefined); + setValue("foo"); + s.flush(); v.expect(data.result()).toEqual( <>

foo

, ); + setValue("bar"); + s.flush(); v.expect(data.result()).toEqual( <>

bar

@@ -222,13 +234,17 @@ v.describe("MatchValue", () => { )), })); v.expect(data.result()).toEqual(undefined); + setValue("foo"); + s.flush(); v.expect(data.result()).toEqual( <>

foo

, ); + setValue("bar"); + s.flush(); v.expect(data.result()).toEqual(undefined); data.dispose(); }); @@ -256,13 +272,17 @@ v.describe("MatchValue", () => {

fallback

, ); + setValue("foo"); + s.flush(); v.expect(data.result()).toEqual( <>

foo

, ); + setValue(undefined); + s.flush(); v.expect(data.result()).toEqual( <>

fallback

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b707db0c4..d02cf2f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -652,9 +652,12 @@ importers: packages/match: 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/media: dependencies: