Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/drop-dead-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@dunky.dev/state-machine-utils': minor
---

Remove the dead exports: the positioning module (`Placement`, `Side`,
`PositioningOptions`, `placementToSide`, `pickSide`), `memo`, and
`composeHandlers`. Nothing in the repo ever consumed them — `mergeProps`
composes handlers through its own private helper, and positioning was
speculative vocabulary for floating components that don't exist yet.
`mergeProps` is now the package's whole surface. Minor (not patch) because
the symbols were publicly exported: any external import of them breaks.
Positioning will come back designed against a real floating component when
one lands.
16 changes: 16 additions & 0 deletions .changeset/export-compose-handlers.md
Original file line number Diff line number Diff line change
@@ -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)
```
12 changes: 6 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The host
|
+-----------------------------------------------------------------------+
| shared/utils |
| Cross-target helpers (mergeProps, composeHandlers, positioning) |
| Cross-target helpers (mergeProps, composeHandlers) |
+-----------------------------------------------------------------------+
| bridged per target
v
Expand Down Expand Up @@ -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, composeHandlers, positioning).
owns cross-target helpers (mergeProps, composeHandlers).

**`<target>/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any
future renderer. Each target is the runtime bridge for one environment: the
Expand Down Expand Up @@ -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, composeHandlers, positioning, memo |
| `packages/shared/utils/` | mergeProps, composeHandlers |
| `packages/<target>/` | Hook + normalize per substrate (react, solid, native, opentui, …) |

## The map
Expand All @@ -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
+-- (composeHandlers, positioning, memo, mergeProps)
+-- (mergeProps, composeHandlers)

<target> one substrate (react, solid, native, opentui, …)
| runtime, hooks, and props translator
Expand All @@ -133,8 +133,8 @@ Three package groups, three jobs:
- **`core/`** — _the agnostic side_. Behavior, types, and the engine that
knows nothing about a renderer.
- **`shared/`** — _the cross-target side_. `shared/bindings` owns the
event + attr vocabulary; `shared/utils` owns agnostic helpers (positioning,
prop merging, memoization).
event + attr vocabulary; `shared/utils` owns agnostic helpers (prop
merging, handler composition).
- **`<target>/`** — _the substrate side_. One folder per renderer
(`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator.

Expand Down
2 changes: 0 additions & 2 deletions packages/shared/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
export * from './utils/memo'
export * from './utils/compose-handlers'
export * from './utils/merge-props'
export * from './utils/positioning'
46 changes: 15 additions & 31 deletions packages/shared/utils/src/utils/compose-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFn = (...args: any[]) => any
type AnyHandler = (...args: unknown[]) => unknown

const composedCache = new WeakMap<AnyFn, WeakMap<AnyFn, AnyFn>>()

export function composeHandlers(
handlers: Record<string, unknown>,
props: Record<string, unknown>,
): void {
for (const key in handlers) {
const internal = handlers[key] as AnyFn
const external = props[key]

if (typeof external === 'function') {
let innerMap = composedCache.get(internal)
if (!innerMap) {
innerMap = new WeakMap()
composedCache.set(internal, innerMap)
}
let composed = innerMap.get(external as AnyFn)
if (!composed) {
composed = (...args: unknown[]) => {
const internalResult = internal(...args)
const externalResult = (external as AnyFn)(...args)
return externalResult ?? internalResult
}
innerMap.set(external as AnyFn, composed)
}
props[key] = composed
} else {
props[key] = handlers[key]
}
/**
* 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)
}
}
42 changes: 0 additions & 42 deletions packages/shared/utils/src/utils/memo.ts

This file was deleted.

16 changes: 3 additions & 13 deletions packages/shared/utils/src/utils/merge-props.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { composeHandlers } from './compose-handlers'

type AnyProps = Record<string, unknown>
type AnyHandler = (...args: unknown[]) => unknown

Expand All @@ -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
Expand All @@ -33,7 +23,7 @@ export function mergeProps<Props extends object = AnyProps>(
const consumerValue = (consumer as AnyProps)[key]

if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) {
out[key] = compose(consumerValue, libValue)
out[key] = composeHandlers(consumerValue, libValue)
continue
}

Expand Down
99 changes: 0 additions & 99 deletions packages/shared/utils/src/utils/positioning.ts

This file was deleted.

Loading
Loading