From 994db793c6549810dcbc4c2348788754d767645d Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 21 Aug 2026 16:37:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(utils):=20export=20composeHandlers=20?= =?UTF-8?q?=E2=80=94=20mergeProps'=20own=20composition,=20made=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the private compose inside mergeProps to a public export in its own file. Same function, same semantics (consumer first, library skipped on the consumer's defaultPrevented): mergeProps now imports it, so nothing changes behaviorally — the composition just becomes reachable for consumers that need to compose a single handler pair outside a prop merge. Unlike the composeHandlers removed in the previous commit (a props-mutating walker with no veto that nothing ever called), this exports the battle-tested implementation the whole stack already runs on. Co-Authored-By: Claude Fable 5 --- .changeset/export-compose-handlers.md | 16 +++++++ ARCHITECTURE.md | 10 ++-- packages/shared/utils/src/index.ts | 1 + .../utils/src/utils/compose-handlers.ts | 18 ++++++++ .../shared/utils/src/utils/merge-props.ts | 16 ++----- .../utils/tests/compose-handlers.test.ts | 46 +++++++++++++++++++ 6 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 .changeset/export-compose-handlers.md create mode 100644 packages/shared/utils/src/utils/compose-handlers.ts create mode 100644 packages/shared/utils/tests/compose-handlers.test.ts diff --git a/.changeset/export-compose-handlers.md b/.changeset/export-compose-handlers.md new file mode 100644 index 0000000..35bbcbc --- /dev/null +++ b/.changeset/export-compose-handlers.md @@ -0,0 +1,16 @@ +--- +'@dunky.dev/state-machine-utils': minor +--- + +Export `composeHandlers` — the handler-pair composition `mergeProps` has +always applied to overlapping `on*` props, now public: the consumer handler +runs first, and the library handler is skipped when the consumer prevented +default (the first argument's `defaultPrevented`, per Radix/Ark conventions). +No behavior change anywhere — `mergeProps` calls the same function; it was +just private before. + +```ts +import { composeHandlers } from '@dunky.dev/state-machine-utils' + +const onClick = composeHandlers(consumerOnClick, libraryOnClick) +``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ddbeb1..0e9731f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,7 +24,7 @@ The host | +-----------------------------------------------------------------------+ | shared/utils | -| Cross-target helpers (mergeProps) | +| Cross-target helpers (mergeProps, composeHandlers) | +-----------------------------------------------------------------------+ | bridged per target v @@ -56,7 +56,7 @@ actions. Nothing in `core/` knows that React or the DOM exists. **`shared/`** is the cross-target side — `shared/bindings` owns the substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils` -owns cross-target helpers (mergeProps). +owns cross-target helpers (mergeProps, composeHandlers). **`/`** is the substrate side — `react`, `native`, `opentui`, and any future renderer. Each target is the runtime bridge for one environment: the @@ -97,7 +97,7 @@ Zag, whose machines read props directly.) | --------------------------- | ------------------------------------------------------------- | | `packages/core/` | State-machine engine (plain-mutation kernel) | | `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | -| `packages/shared/utils/` | mergeProps | +| `packages/shared/utils/` | mergeProps, composeHandlers | | `packages//` | Hook + normalize per substrate (react, native, opentui, …) | ## The map @@ -119,7 +119,7 @@ shared/bindings substrate-agnostic event + attr vocabulary +-- (onPress, role, aria-*, …) consumed by every target's normalize shared/utils cross-target, cross-component helpers -+-- (mergeProps) ++-- (mergeProps, composeHandlers) one substrate (react, native, opentui, …) | runtime, hooks, and props translator @@ -134,7 +134,7 @@ Three package groups, three jobs: knows nothing about a renderer. - **`shared/`** — _the cross-target side_. `shared/bindings` owns the event + attr vocabulary; `shared/utils` owns agnostic helpers (prop - merging). + merging, handler composition). - **`/`** — _the substrate side_. One folder per renderer (`react`, `native`, `opentui`). Owns its runtime bridge and its props translator. diff --git a/packages/shared/utils/src/index.ts b/packages/shared/utils/src/index.ts index ea23901..67e7ea0 100644 --- a/packages/shared/utils/src/index.ts +++ b/packages/shared/utils/src/index.ts @@ -1 +1,2 @@ +export * from './utils/compose-handlers' export * from './utils/merge-props' diff --git a/packages/shared/utils/src/utils/compose-handlers.ts b/packages/shared/utils/src/utils/compose-handlers.ts new file mode 100644 index 0000000..9e77fda --- /dev/null +++ b/packages/shared/utils/src/utils/compose-handlers.ts @@ -0,0 +1,18 @@ +type AnyHandler = (...args: unknown[]) => unknown + +/** + * Chain a consumer handler before a library handler: the consumer runs first, + * and the library handler is skipped when the consumer prevented default — if + * the first argument looks like an event whose `defaultPrevented` is set, the + * chain stops there. This matches Radix/Ark conventions and is the exact + * composition `mergeProps` applies to overlapping `on*` props; exported for + * consumers that need to compose a single handler pair outside a prop merge. + */ +export function composeHandlers(consumer: AnyHandler, library: AnyHandler): AnyHandler { + return (...args) => { + consumer(...args) + const event = args[0] as { defaultPrevented?: boolean } | undefined + if (event && typeof event === 'object' && event.defaultPrevented) return + return library(...args) + } +} diff --git a/packages/shared/utils/src/utils/merge-props.ts b/packages/shared/utils/src/utils/merge-props.ts index 7de8550..a776c0e 100644 --- a/packages/shared/utils/src/utils/merge-props.ts +++ b/packages/shared/utils/src/utils/merge-props.ts @@ -1,3 +1,5 @@ +import { composeHandlers } from './compose-handlers' + type AnyProps = Record type AnyHandler = (...args: unknown[]) => unknown @@ -6,18 +8,6 @@ const isEventHandlerKey = (key: string): boolean => const isFn = (v: unknown): v is AnyHandler => typeof v === 'function' -function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler { - return (...args) => { - consumer(...args) - // Respect consumer's defaultPrevented — if the first arg looks like - // an event whose default was prevented, the library handler is - // skipped. This matches Radix/Ark conventions. - const event = args[0] as { defaultPrevented?: boolean } | undefined - if (event && typeof event === 'object' && event.defaultPrevented) return - return library(...args) - } -} - // Generic over the consumer's props so framework prop types (interfaces // without an index signature) pass in and come back out cast-free. The return // is the Object.assign-style intersection: assignable to the consumer's props @@ -33,7 +23,7 @@ export function mergeProps( const consumerValue = (consumer as AnyProps)[key] if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) { - out[key] = compose(consumerValue, libValue) + out[key] = composeHandlers(consumerValue, libValue) continue } diff --git a/packages/shared/utils/tests/compose-handlers.test.ts b/packages/shared/utils/tests/compose-handlers.test.ts new file mode 100644 index 0000000..c2607ab --- /dev/null +++ b/packages/shared/utils/tests/compose-handlers.test.ts @@ -0,0 +1,46 @@ +/** + * `composeHandlers` — the public handler-pair composition, the same function + * `mergeProps` applies to overlapping `on*` props. Consumer first, library + * after, with the consumer's `defaultPrevented` as the veto. + */ +import { describe, expect, it, vi } from 'vitest' +import { composeHandlers } from '@dunky.dev/state-machine-utils' + +describe('composeHandlers', () => { + it('runs the consumer first, then the library handler', () => { + const order: string[] = [] + const composed = composeHandlers( + () => order.push('consumer'), + () => order.push('library'), + ) + composed({ defaultPrevented: false }) + expect(order).toEqual(['consumer', 'library']) + }) + + it('skips the library handler when the consumer prevented default (veto)', () => { + const library = vi.fn() + const composed = composeHandlers( + (e: unknown) => ((e as { defaultPrevented: boolean }).defaultPrevented = true), + library, + ) + composed({ defaultPrevented: false }) + expect(library).not.toHaveBeenCalled() + }) + + it('returns the library handler result (undefined when vetoed)', () => { + const composed = composeHandlers( + () => 'consumer', + () => 'library', + ) + expect(composed({ defaultPrevented: false })).toBe('library') + expect(composed({ defaultPrevented: true })).toBeUndefined() + }) + + it('runs both when the first argument is not an event shape', () => { + const library = vi.fn() + const composed = composeHandlers(vi.fn(), library) + composed('plain-string') + composed() + expect(library).toHaveBeenCalledTimes(2) + }) +})