From 8f2e6042ea10ef7fec5eb7375e619808727bbffe Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 02:38:40 +0700 Subject: [PATCH] Controllable signal primitive Implements the controlled/uncontrolled state primitive with full TDD coverage (5 tests passing). Delegates to an internal signal when uncontrolled, defers to the external value accessor when controlled. --- .../primitives/create-controllable-signal.ts | 36 ++++++++++ .../create-controllable-signal.test.ts | 67 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/core/src/primitives/create-controllable-signal.ts create mode 100644 packages/core/tests/primitives/create-controllable-signal.test.ts diff --git a/packages/core/src/primitives/create-controllable-signal.ts b/packages/core/src/primitives/create-controllable-signal.ts new file mode 100644 index 0000000..9a66cd0 --- /dev/null +++ b/packages/core/src/primitives/create-controllable-signal.ts @@ -0,0 +1,36 @@ +import { type Accessor, createSignal } from "solid-js"; + +export interface CreateControllableSignalOptions { + /** Returns the controlled value, or undefined if uncontrolled. */ + value: Accessor; + /** Default value used when uncontrolled. */ + defaultValue: Accessor; + /** Called whenever the value changes (both modes). */ + onChange?: (value: T) => void; +} + +/** + * Handles controlled vs uncontrolled state for any stateful component. + * When `value()` is not undefined, the component is controlled — the external + * value is the source of truth. Otherwise, an internal signal manages state. + */ +export function createControllableSignal( + options: CreateControllableSignalOptions, +): [Accessor, (value: T) => void] { + const [internalValue, setInternalValue] = createSignal(options.defaultValue()); + + const get: Accessor = () => { + const controlled = options.value(); + return controlled !== undefined ? controlled : internalValue(); + }; + + const set = (value: T) => { + const isControlled = options.value() !== undefined; + if (!isControlled) { + setInternalValue(() => value); + } + options.onChange?.(value); + }; + + return [get, set]; +} diff --git a/packages/core/tests/primitives/create-controllable-signal.test.ts b/packages/core/tests/primitives/create-controllable-signal.test.ts new file mode 100644 index 0000000..8789b0e --- /dev/null +++ b/packages/core/tests/primitives/create-controllable-signal.test.ts @@ -0,0 +1,67 @@ +import { createRoot, createSignal } from "solid-js"; +import { describe, expect, it, vi } from "vitest"; +import { createControllableSignal } from "../../src/primitives/create-controllable-signal"; + +describe("createControllableSignal", () => { + it("uses defaultValue when value accessor returns undefined (uncontrolled)", () => { + createRoot((dispose) => { + const [get] = createControllableSignal({ + value: () => undefined, + defaultValue: () => false, + }); + expect(get()).toBe(false); + dispose(); + }); + }); + + it("uses value accessor when provided (controlled)", () => { + createRoot((dispose) => { + const [get] = createControllableSignal({ + value: () => true, + defaultValue: () => false, + }); + expect(get()).toBe(true); + dispose(); + }); + }); + + it("calls onChange when setter is called in uncontrolled mode", () => { + createRoot((dispose) => { + const onChange = vi.fn(); + const [, set] = createControllableSignal({ + value: () => undefined, + defaultValue: () => false, + onChange, + }); + set(true); + expect(onChange).toHaveBeenCalledWith(true); + dispose(); + }); + }); + + it("updates internal signal when setter called in uncontrolled mode", () => { + createRoot((dispose) => { + const [get, set] = createControllableSignal({ + value: () => undefined, + defaultValue: () => "initial", + }); + set("updated"); + expect(get()).toBe("updated"); + dispose(); + }); + }); + + it("does not update internal signal in controlled mode — external value is source of truth", () => { + createRoot((dispose) => { + const [externalValue] = createSignal("controlled"); + const [get, set] = createControllableSignal({ + value: externalValue, + defaultValue: () => "default", + }); + set("ignored"); + // Still reflects external value since we're controlled + expect(get()).toBe("controlled"); + dispose(); + }); + }); +});