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
-
-
-
- Select an animal:
- {
- setAnimal(animals[parseInt(e.target.value)]!);
- }}
- >
- None (null)
- Dog
- Cat
- Bird
-
-
-
-
-
-
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 */}
+
+ setShape(null)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ None
+
+
+ {s => (
+ setShape(s)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ {s.type}
+
+ )}
+
+
+
+ {/* 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
+
+
+ setNotif(null)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ Clear
+
+
+ {n => (
+ setNotif(n)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ {n.kind}
+
+ )}
+
+
+
+
+ (
+
+ 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.
+
+
+
+ setEvt(null)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ Clear
+
+
+ {e => (
+ setEvt(e)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ {e.type}
+
+ )}
+
+
+
+
+ (
+
+ βΆ
+ 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 => (
+ setStatus(s)}
+ style={{ "font-size": font.sizeSm }}
+ >
+ {STATUS_LABEL[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: