@octanejs/zag 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # @octanejs/zag
2
+
3
+ [Zag](https://zagjs.com/) state-machine bindings for the
4
+ [Octane](https://github.com/octanejs/octane) UI framework.
5
+
6
+ This package ports `@zag-js/react@1.42.0` onto Octane while reusing the
7
+ framework-agnostic `@zag-js/core`, `@zag-js/store`, `@zag-js/types`, and
8
+ `@zag-js/utils` packages unchanged. It is the runtime substrate for Octane
9
+ ports of component libraries built on Zag, including Ark UI and Chakra UI.
10
+
11
+ Change the framework adapter import while keeping machine definitions and
12
+ connect functions on their existing `@zag-js/*` packages:
13
+
14
+ ```ts
15
+ // before
16
+ import { normalizeProps, useMachine } from '@zag-js/react';
17
+
18
+ // after
19
+ import { normalizeProps, useMachine } from '@octanejs/zag';
20
+ ```
21
+
22
+ ```tsx
23
+ import { normalizeProps, useMachine } from '@octanejs/zag';
24
+ import * as toggle from '@zag-js/toggle';
25
+
26
+ export function Toggle() @{
27
+ const service = useMachine(toggle.machine, { id: 'example' });
28
+ const api = toggle.connect(service, normalizeProps);
29
+
30
+ <button {...api.getRootProps()}>{api.pressed ? 'On' : 'Off'}</button>
31
+ }
32
+ ```
33
+
34
+ ## API
35
+
36
+ The public surface matches `@zag-js/react@1.42.0`:
37
+
38
+ - `useMachine` runs a Zag machine with Octane hooks.
39
+ - `normalizeProps` types Zag-generated host props for Octane JSX.
40
+ - `Portal` renders into `document.body` or a supplied container and supports
41
+ disabled and server-rendered in-place output.
42
+ - `mergeProps` is re-exported from `@zag-js/core`.
43
+ - `useSyncExternalStore` is re-exported from Octane.
44
+
45
+ The adapter forwards Octane's compiler-assigned hook slots through every
46
+ composed hook and through Zag's indirect `bindable` and `track` callbacks.
47
+ Distinct machines and bindable context values therefore remain independent.
48
+
49
+ ## Verification
50
+
51
+ Behavioral tests cover transitions, bindable context, independent hook call
52
+ sites, multi-child portals, disabled portals, and server rendering. A
53
+ differential test runs the same machine through this package and
54
+ `@zag-js/react@1.42.0` and compares the state/context trace. Published source
55
+ and dependency checks ensure React is development-only.
56
+
57
+ Current scope and verification status are tracked in the generated
58
+ [bindings status table](../../docs/bindings-status.md), sourced from this
59
+ package's [`status.json`](./status.json).
package/UPSTREAM.md ADDED
@@ -0,0 +1,76 @@
1
+ # @zag-js/react upstream contract
2
+
3
+ ## Pin and source boundary
4
+
5
+ | Field | Value |
6
+ |---|---|
7
+ | Package | `@zag-js/react` |
8
+ | Version | `1.42.0` |
9
+ | Canonical tag commit | `df65e4c87c75a1c84eb6eb08a8e30dac0e1bb77f` |
10
+ | Supported upstream range | exactly `1.42.0` |
11
+ | React oracle | `19.2.7` with `@types/react@19.2.7` and `@types/react-dom@19.2.3` (`catalog:zag-react-oracle`) |
12
+ | Testing Library oracle | `@testing-library/react@16.3.2` (`catalog:zag-react-oracle`; satisfies upstream `^16.3.2`) |
13
+ | jsdom oracle | `29.0.1` (`catalog:zag-react-oracle`; satisfies upstream `^29.0.1`) |
14
+ | Vendored tree integrity | `sha256:037f857d00462559b9790acc2214c818678eb9d61970d6c394dc26dcfcd1516d` (`packages/zag/upstream/SHA256SUMS`) |
15
+ | License | MIT, © Chakra UI |
16
+
17
+ Repository: `https://github.com/chakra-ui/zag.git`. The npm artifact publishes
18
+ compiled `dist/` output. The framework adapter source and its Vitest suite at
19
+ this pin live under `packages/frameworks/react` in the canonical repository and
20
+ are vendored byte-exact at `packages/zag/upstream/` (MIT). Framework-agnostic
21
+ `@zag-js/core`, `@zag-js/store`, `@zag-js/types`, and `@zag-js/utils` at the same
22
+ version are reused unchanged and are not reimplemented here.
23
+
24
+ ## Runtime export crosswalk
25
+
26
+ | Upstream export | Octane disposition | Evidence |
27
+ |---|---|---|
28
+ | `useMachine` | Ported to Octane hooks in `src/machine.ts` | `tests/upstream/machine.test.ts`, `tests/upstream/nested-states.test.ts`, `tests/differential/machine.test.ts` |
29
+ | `normalizeProps` | Ported in `src/normalize-props.ts` with documented text-host event rewrite | Framework-contract: `tests/conformance/upstream-surface.test.ts`. Not an upstream-suite identity; see Intentional divergences. |
30
+ | `Portal` | Ported in `src/portal.ts` | Differential: `tests/differential/portal.test.ts`. Framework-contract / SSR pins: `tests/conformance/machine.test.ts`, `tests/ssr/server.test.ts` (not counted as React-parity identities). |
31
+ | `mergeProps` | Re-exported from `@zag-js/core` | Structural export inventory in `tests/conformance/upstream-surface.test.ts` |
32
+ | `useSyncExternalStore` | Re-exported from Octane | Structural export inventory in `tests/conformance/upstream-surface.test.ts` |
33
+
34
+ ## Intentional divergences
35
+
36
+ - **Text-host `onChange` → `onInput`.** Upstream `normalizeProps` is an identity
37
+ normalizer. Octane rewrites React-style text-entry `onChange` to native
38
+ `onInput` for `normalizeProps.textarea` and `normalizeProps.input` when the
39
+ type is a text-entry host (`text`/`search`/`url`/`tel`/`password`/`email`/
40
+ `number`, plus missing/invalid types that HTML defaults to text).
41
+ `<select>` and non-text inputs (`checkbox`/`radio`/`file`/`date`/`range`/
42
+ …) keep native `onChange`. Upstream has no cases for this export; the rewrite
43
+ is pinned by `tests/conformance/upstream-surface.test.ts` and recorded in
44
+ `status.json`.
45
+ - **Portal ref / children shape.** Container refs use Octane's structural
46
+ `{ current: HTMLElement | null }` rather than `React.RefObject`. Compiled
47
+ Octane children blocks portal as one unit so component scope is preserved;
48
+ ordinary value children keep upstream's per-child portal behavior. Mount /
49
+ disabled / root-node behavior is compared against `@zag-js/react@1.42.0` in
50
+ `tests/differential/portal.test.ts`.
51
+
52
+ ## Test-suite disposition
53
+
54
+ Upstream `@zag-js/react` ships a Vitest runtime suite under
55
+ `packages/frameworks/react/tests` at this pin (`machine.test.ts`,
56
+ `nested-states.test.ts`, `strict-mode.test.tsx`, plus `render.ts`,
57
+ `vite.config.ts`, and `vitest.setup.ts`). That suite is recorded as present and
58
+ locked under `packages/zag/upstream/`.
59
+
60
+ | Upstream artifact | Disposition | Octane location / notes |
61
+ |---|---|---|
62
+ | `tests/machine.test.ts` | Ported one-for-one | `tests/upstream/machine.test.ts` |
63
+ | `tests/nested-states.test.ts` | Ported one-for-one | `tests/upstream/nested-states.test.ts` |
64
+ | `tests/strict-mode.test.tsx` | Out of scope for adapted Octane lane | React StrictMode double-invoke is an intentional Octane non-feature; still executed in the pristine React lane. Exact pristine case IDs and rationale are locked in `audit/runtime-case-dispositions.json` and checked by the pristine→adapted inventory crosswalk. |
65
+ | `tests/render.ts` | Ported helper | `tests/upstream/render.ts` |
66
+
67
+ Parity evidence:
68
+
69
+ - pristine full suite: `zag-pristine` Vitest project / `tests/upstream-original.test.ts`
70
+ - adapted full suite: `zag` Vitest project / `tests/upstream/**/*.test.ts`
71
+ - differential (supplementary): `zag-differential` / `tests/differential/*.test.ts`
72
+ - pristine types: `packages/zag/audit/type-probes` via `tsc`
73
+ - adapted types: `packages/zag/typetests` via `tsrx-tsc`
74
+
75
+ Conformance and SSR tests remain ordinary `zag` / `zag-ssr` package coverage and
76
+ are not counted as adapted upstream-suite identities.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@octanejs/zag",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.22.2"
8
+ },
9
+ "octane": {
10
+ "hookSlots": {
11
+ "manual": [
12
+ "src"
13
+ ]
14
+ }
15
+ },
16
+ "description": "Zag state-machine bindings for Octane, backed by the framework-agnostic @zag-js core packages.",
17
+ "author": {
18
+ "name": "Dominic Gannaway",
19
+ "email": "dg@domgan.com"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/octanejs/octane.git",
27
+ "directory": "packages/zag"
28
+ },
29
+ "main": "src/index.ts",
30
+ "module": "src/index.ts",
31
+ "types": "src/index.ts",
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "UPSTREAM.md"
36
+ ],
37
+ "exports": {
38
+ ".": "./src/index.ts"
39
+ },
40
+ "dependencies": {
41
+ "@zag-js/core": "1.42.0",
42
+ "@zag-js/store": "1.42.0",
43
+ "@zag-js/types": "1.42.0",
44
+ "@zag-js/utils": "1.42.0"
45
+ },
46
+ "peerDependencies": {
47
+ "octane": "0.1.35"
48
+ },
49
+ "devDependencies": {
50
+ "@tsrx/react": "^0.2.56",
51
+ "@types/react": "19.2.7",
52
+ "@types/react-dom": "19.2.3",
53
+ "@zag-js/react": "1.42.0",
54
+ "esbuild": "^0.28.1",
55
+ "react": "19.2.7",
56
+ "react-dom": "19.2.7",
57
+ "vitest": "^4.1.10",
58
+ "@testing-library/jest-dom": "^6.9.1",
59
+ "@testing-library/react": "16.3.2",
60
+ "@vitejs/plugin-react": "^6.0.3",
61
+ "jsdom": "29.0.1",
62
+ "octane": "0.1.35",
63
+ "@octanejs/testing-library": "0.1.32"
64
+ },
65
+ "scripts": {
66
+ "test": "vitest run --root ../.. --project zag --project zag-differential --project zag-ssr --project zag-pristine",
67
+ "test:upstream": "node scripts/run-pristine-upstream.mjs",
68
+ "typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.json",
69
+ "upstream:verify": "node scripts/verify-upstream.mjs"
70
+ }
71
+ }
@@ -0,0 +1,86 @@
1
+ import type { Bindable, BindableParams } from '@zag-js/core';
2
+ import { isFunction } from '@zag-js/utils';
3
+ import { flushSync, useEffect, useRef, useState } from 'octane';
4
+
5
+ import { subSlot } from './internal';
6
+ import { useSafeLayoutEffect } from './use-layout-effect';
7
+
8
+ function useBindableValue<T>(
9
+ props: () => BindableParams<T>,
10
+ slot: symbol | undefined,
11
+ ): Bindable<T> {
12
+ const initial = props().value ?? props().defaultValue;
13
+ const eq = props().isEqual ?? Object.is;
14
+
15
+ const [initialValue] = useState(initial, subSlot(slot, 'initial'));
16
+ const [value, setValue] = useState(initialValue, subSlot(slot, 'value'));
17
+ const controlled = props().value !== undefined;
18
+
19
+ const valueRef = useRef(value, subSlot(slot, 'value-ref'));
20
+ valueRef.current = controlled ? props().value : value;
21
+
22
+ const prevValue = useRef(valueRef.current, subSlot(slot, 'previous-value'));
23
+ useSafeLayoutEffect(
24
+ () => {
25
+ prevValue.current = valueRef.current;
26
+ },
27
+ [value, props().value],
28
+ subSlot(slot, 'sync-previous'),
29
+ );
30
+
31
+ const setFn = (nextValue: T | ((prev: T) => T)) => {
32
+ const previous = prevValue.current;
33
+ const next = isFunction(nextValue) ? nextValue(previous as T) : nextValue;
34
+
35
+ if (props().debug) {
36
+ console.log(`[bindable > ${props().debug}] setValue`, { next, prev: previous });
37
+ }
38
+
39
+ // Controlled values stay pinned to props().value (re-synced each render).
40
+ // Uncontrolled updates advance valueRef immediately so mid-transition
41
+ // action chains read the value set() already wrote.
42
+ if (!controlled) {
43
+ valueRef.current = next;
44
+ prevValue.current = next;
45
+ setValue(next);
46
+ }
47
+ if (!eq(next, previous)) props().onChange?.(next, previous);
48
+ };
49
+
50
+ return {
51
+ initial: initialValue,
52
+ ref: valueRef,
53
+ get() {
54
+ return (controlled ? props().value : valueRef.current) as T;
55
+ },
56
+ set(nextValue: T | ((prev: T) => T)) {
57
+ const execute = props().sync ? flushSync : (run: () => void) => run();
58
+ execute(() => setFn(nextValue));
59
+ },
60
+ invoke(nextValue: T, previousValue: T) {
61
+ props().onChange?.(nextValue, previousValue);
62
+ },
63
+ hash(nextValue: T) {
64
+ return props().hash?.(nextValue) ?? String(nextValue);
65
+ },
66
+ };
67
+ }
68
+
69
+ function cleanup(fn: VoidFunction, slot?: symbol) {
70
+ useEffect(() => fn, [], subSlot(slot, 'cleanup'));
71
+ }
72
+
73
+ function bindableRef<T>(defaultValue: T, slot?: symbol) {
74
+ const value = useRef(defaultValue, subSlot(slot, 'ref'));
75
+ return {
76
+ get: () => value.current,
77
+ set: (next: T) => {
78
+ value.current = next;
79
+ },
80
+ };
81
+ }
82
+
83
+ export const useBindable = Object.assign(
84
+ <T>(props: () => BindableParams<T>, slot?: symbol) => useBindableValue(props, slot),
85
+ { cleanup, ref: bindableRef },
86
+ );
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { mergeProps } from '@zag-js/core';
2
+ export { useSyncExternalStore } from 'octane';
3
+ export * from './machine';
4
+ export * from './normalize-props';
5
+ export * from './portal';
@@ -0,0 +1,32 @@
1
+ const subSlotCache = new Map<symbol, Map<string, symbol>>();
2
+ const bareSlotCache = new Map<string, symbol>();
3
+
4
+ export function subSlot(slot: symbol | undefined, tag: string): symbol {
5
+ if (slot === undefined) {
6
+ let bare = bareSlotCache.get(tag);
7
+ if (bare === undefined) {
8
+ bare = Symbol.for(`@octanejs/zag:${tag}`);
9
+ bareSlotCache.set(tag, bare);
10
+ }
11
+ return bare;
12
+ }
13
+
14
+ let byTag = subSlotCache.get(slot);
15
+ if (byTag === undefined) {
16
+ byTag = new Map();
17
+ subSlotCache.set(slot, byTag);
18
+ }
19
+
20
+ let child = byTag.get(tag);
21
+ if (child === undefined) {
22
+ child = Symbol.for(`${slot.description ?? ''}:${tag}`);
23
+ byTag.set(tag, child);
24
+ }
25
+ return child;
26
+ }
27
+
28
+ export function splitSlot(args: unknown[]): [unknown[], symbol | undefined] {
29
+ const tail = args[args.length - 1];
30
+ const slot = typeof tail === 'symbol' ? tail : undefined;
31
+ return [slot === undefined ? args : args.slice(0, -1), slot];
32
+ }
package/src/machine.ts ADDED
@@ -0,0 +1,352 @@
1
+ import type {
2
+ ActionsOrFn,
3
+ Bindable,
4
+ BindableContext,
5
+ BindableRefs,
6
+ ChooseFn,
7
+ ComputedFn,
8
+ EffectsOrFn,
9
+ GuardFn,
10
+ Machine,
11
+ MachineSchema,
12
+ Params,
13
+ Service,
14
+ } from '@zag-js/core';
15
+ import {
16
+ createScope,
17
+ findTransition,
18
+ getExitEnterStates,
19
+ hasTag,
20
+ INIT_STATE,
21
+ MachineStatus,
22
+ matchesState,
23
+ resolveStateValue,
24
+ } from '@zag-js/core';
25
+ import { callAll, compact, ensure, isFunction, isString, toArray, warn } from '@zag-js/utils';
26
+ import { flushSync, useMemo, useRef } from 'octane';
27
+
28
+ import { useBindable } from './bindable';
29
+ import { splitSlot, subSlot } from './internal';
30
+ import { useRefs } from './refs';
31
+ import { useTrack } from './track';
32
+ import { useSafeLayoutEffect } from './use-layout-effect';
33
+
34
+ export function useMachine<T extends MachineSchema>(
35
+ machine: Machine<T>,
36
+ userProps?: Partial<T['props']>,
37
+ ): Service<T>;
38
+ export function useMachine<T extends MachineSchema>(...rawArgs: unknown[]): Service<T> {
39
+ const [args, slot] = splitSlot(rawArgs);
40
+ const [machine, suppliedProps = {}] = args as [Machine<T>, Partial<T['props']>?];
41
+ const userProps = suppliedProps ?? {};
42
+
43
+ const scope = useMemo(
44
+ () => {
45
+ const { id, ids, getRootNode } = userProps as any;
46
+ return createScope({ id, ids, getRootNode });
47
+ },
48
+ [userProps],
49
+ subSlot(slot, 'scope'),
50
+ );
51
+
52
+ const debug = (...args: any[]) => {
53
+ if (machine.debug) console.log(...args);
54
+ };
55
+
56
+ const props: any = machine.props?.({ props: compact(userProps), scope }) ?? userProps;
57
+ const prop = useProp(props, subSlot(slot, 'prop'));
58
+
59
+ let bindableIndex = 0;
60
+ let cleanupIndex = 0;
61
+ let bindableRefIndex = 0;
62
+ const bindable = Object.assign(
63
+ <TValue>(params: () => any) =>
64
+ useBindable<TValue>(params, subSlot(slot, `context-bindable:${bindableIndex++}`)),
65
+ {
66
+ cleanup: (fn: VoidFunction) =>
67
+ useBindable.cleanup(fn, subSlot(slot, `context-cleanup:${cleanupIndex++}`)),
68
+ ref: <TValue>(defaultValue: TValue) =>
69
+ useBindable.ref(defaultValue, subSlot(slot, `context-ref:${bindableRefIndex++}`)),
70
+ },
71
+ ) as typeof useBindable;
72
+
73
+ const context = machine.context?.({
74
+ prop,
75
+ bindable,
76
+ scope,
77
+ flush,
78
+ getContext() {
79
+ return ctx as any;
80
+ },
81
+ getComputed() {
82
+ return computed as any;
83
+ },
84
+ getRefs() {
85
+ return refs as any;
86
+ },
87
+ getEvent() {
88
+ return getEvent();
89
+ },
90
+ });
91
+
92
+ const contextRef = useLiveRef<any>(context, subSlot(slot, 'context'));
93
+ const ctx: BindableContext<T> = {
94
+ get(key) {
95
+ return contextRef.current?.[key].ref.current;
96
+ },
97
+ set(key, value) {
98
+ contextRef.current?.[key].set(value);
99
+ },
100
+ initial(key) {
101
+ return contextRef.current?.[key].initial;
102
+ },
103
+ hash(key) {
104
+ const current = contextRef.current?.[key].ref.current;
105
+ return contextRef.current?.[key].hash(current);
106
+ },
107
+ };
108
+
109
+ const effects = useRef(new Map<string, VoidFunction>(), subSlot(slot, 'effects'));
110
+ const transitionRef = useRef<any>(null, subSlot(slot, 'transition'));
111
+ const previousEventRef = useRef<any>(null, subSlot(slot, 'previous-event'));
112
+ const eventRef = useRef<any>({ type: '' }, subSlot(slot, 'event'));
113
+
114
+ const getEvent = () => ({
115
+ ...eventRef.current,
116
+ current() {
117
+ return eventRef.current;
118
+ },
119
+ previous() {
120
+ return previousEventRef.current;
121
+ },
122
+ });
123
+
124
+ const getState = () => ({
125
+ ...state,
126
+ matches(...values: T['state'][]) {
127
+ return values.some((value) => matchesState(state.ref.current as string, value as string));
128
+ },
129
+ hasTag(tag: T['tag']) {
130
+ return hasTag(machine, state.ref.current, tag);
131
+ },
132
+ });
133
+
134
+ const refs: BindableRefs<T> = useRefs(
135
+ machine.refs?.({ prop, context: ctx }) ?? {},
136
+ subSlot(slot, 'refs'),
137
+ );
138
+
139
+ let trackIndex = 0;
140
+ const track = (deps: any[], trackedEffect: VoidFunction) =>
141
+ useTrack(deps, trackedEffect, subSlot(slot, `track:${trackIndex++}`));
142
+
143
+ const getParams = (): Params<T> => ({
144
+ state: getState(),
145
+ context: ctx,
146
+ event: getEvent(),
147
+ prop,
148
+ send,
149
+ action,
150
+ guard,
151
+ track,
152
+ refs,
153
+ computed,
154
+ flush,
155
+ scope,
156
+ choose,
157
+ });
158
+
159
+ const action = (keys: ActionsOrFn<T> | undefined) => {
160
+ const strings = isFunction(keys) ? keys(getParams()) : keys;
161
+ if (!strings) return;
162
+ const functions = strings.map((key) => {
163
+ const fn = machine.implementations?.actions?.[key];
164
+ if (!fn) warn(`[zag-js] No implementation found for action "${JSON.stringify(key)}"`);
165
+ return fn;
166
+ });
167
+ for (const fn of functions) fn?.(getParams());
168
+ };
169
+
170
+ const guard = (key: T['guard'] | GuardFn<T>) => {
171
+ if (isFunction(key)) return key(getParams());
172
+ const fn = machine.implementations?.guards?.[key];
173
+ if (!fn) warn(`[zag-js] No implementation found for guard "${JSON.stringify(key)}"`);
174
+ return fn?.(getParams());
175
+ };
176
+
177
+ const effect = (keys: EffectsOrFn<T> | undefined) => {
178
+ const strings = isFunction(keys) ? keys(getParams()) : keys;
179
+ if (!strings) return;
180
+ const functions = strings.map((key) => {
181
+ const fn = machine.implementations?.effects?.[key];
182
+ if (!fn) warn(`[zag-js] No implementation found for effect "${JSON.stringify(key)}"`);
183
+ return fn;
184
+ });
185
+ const cleanups: VoidFunction[] = [];
186
+ for (const fn of functions) {
187
+ const cleanup = fn?.(getParams());
188
+ if (cleanup) cleanups.push(cleanup);
189
+ }
190
+ return () => cleanups.forEach((cleanup) => cleanup?.());
191
+ };
192
+
193
+ const choose: ChooseFn<T> = (transitions) =>
194
+ toArray(transitions).find((transition) => {
195
+ let result = !transition.guard;
196
+ if (isString(transition.guard)) result = !!guard(transition.guard);
197
+ else if (isFunction(transition.guard)) result = transition.guard(getParams());
198
+ return result;
199
+ });
200
+
201
+ const computed: ComputedFn<T> = (key) => {
202
+ ensure(machine.computed, () => '[zag-js] No computed object found on machine');
203
+ const fn = machine.computed[key];
204
+ return fn({
205
+ context: ctx as any,
206
+ event: getEvent(),
207
+ prop,
208
+ refs,
209
+ scope,
210
+ computed: computed as any,
211
+ });
212
+ };
213
+
214
+ const state = useBindable(
215
+ () => ({
216
+ defaultValue: resolveStateValue(machine, machine.initialState({ prop })),
217
+ onChange(nextState, previousState) {
218
+ const { exiting, entering } = getExitEnterStates(
219
+ machine,
220
+ previousState,
221
+ nextState,
222
+ transitionRef.current?.reenter,
223
+ );
224
+
225
+ exiting.forEach((item) => {
226
+ effects.current.get(item.path)?.();
227
+ effects.current.delete(item.path);
228
+ });
229
+ exiting.forEach((item) => action(item.state?.exit));
230
+ action(transitionRef.current?.actions);
231
+
232
+ entering.forEach((item) => {
233
+ const cleanup = effect(item.state?.effects);
234
+ if (!cleanup) return;
235
+ const existing = effects.current.get(item.path);
236
+ effects.current.set(item.path, existing ? callAll(existing, cleanup) : cleanup);
237
+ });
238
+
239
+ if (previousState === INIT_STATE) {
240
+ action(machine.entry);
241
+ const cleanup = effect(machine.effects);
242
+ if (cleanup) {
243
+ const existing = effects.current.get(INIT_STATE);
244
+ effects.current.set(INIT_STATE, existing ? callAll(existing, cleanup) : cleanup);
245
+ }
246
+ }
247
+
248
+ entering.forEach((item) => action(item.state?.entry));
249
+ },
250
+ }),
251
+ subSlot(slot, 'state'),
252
+ );
253
+
254
+ const hydratedStateRef = useRef<string | undefined>(undefined, subSlot(slot, 'hydrated-state'));
255
+ const statusRef = useRef(MachineStatus.NotStarted, subSlot(slot, 'status'));
256
+
257
+ useSafeLayoutEffect(
258
+ () => {
259
+ let cancelled = false;
260
+ let didStart = false;
261
+ queueMicrotask(() => {
262
+ if (cancelled) return;
263
+ const started = statusRef.current === MachineStatus.Started;
264
+ didStart = true;
265
+ statusRef.current = MachineStatus.Started;
266
+ debug(started ? 'rehydrating...' : 'initializing...');
267
+ const initialState = hydratedStateRef.current ?? state.initial!;
268
+ state.invoke(initialState, started ? state.get() : INIT_STATE);
269
+ });
270
+
271
+ const functions = effects.current;
272
+ return () => {
273
+ cancelled = true;
274
+ const currentState = getCurrentState();
275
+ debug('unmounting...');
276
+ hydratedStateRef.current = currentState;
277
+ statusRef.current = MachineStatus.Stopped;
278
+ functions.forEach((fn) => fn?.());
279
+ effects.current = new Map();
280
+ transitionRef.current = null;
281
+
282
+ queueMicrotask(() => {
283
+ if (didStart) action(machine.exit);
284
+ statusRef.current = MachineStatus.Stopped;
285
+ });
286
+ };
287
+ },
288
+ [],
289
+ subSlot(slot, 'lifecycle'),
290
+ );
291
+
292
+ const getCurrentState = () =>
293
+ 'ref' in state ? state.ref.current : (state as Bindable<string>).get();
294
+
295
+ const send = (event: any) => {
296
+ queueMicrotask(() => {
297
+ if (statusRef.current !== MachineStatus.Started) return;
298
+
299
+ previousEventRef.current = eventRef.current;
300
+ eventRef.current = event;
301
+ const currentState = getCurrentState();
302
+ const { transitions, source } = findTransition(machine, currentState, event.type as string);
303
+ const transition = choose(transitions);
304
+ if (!transition) return;
305
+
306
+ transitionRef.current = transition;
307
+ const target = resolveStateValue(machine, transition.target ?? currentState, source);
308
+ debug('transition', event.type, transition.target || currentState, `(${transition.actions})`);
309
+
310
+ if (target !== currentState) {
311
+ flushSync(() => state.set(target));
312
+ } else if (transition.reenter) {
313
+ state.invoke(currentState, currentState);
314
+ } else {
315
+ action(transition.actions ?? []);
316
+ }
317
+ });
318
+ };
319
+
320
+ machine.watch?.(getParams());
321
+
322
+ return {
323
+ state: getState(),
324
+ send,
325
+ context: ctx,
326
+ prop,
327
+ scope,
328
+ refs,
329
+ computed,
330
+ event: getEvent(),
331
+ getStatus: () => statusRef.current,
332
+ } as Service<T>;
333
+ }
334
+
335
+ function useLiveRef<T>(value: T, slot: symbol) {
336
+ const ref = useRef(value, slot);
337
+ ref.current = value;
338
+ return ref;
339
+ }
340
+
341
+ function useProp<T>(value: T, slot: symbol) {
342
+ const ref = useLiveRef(value, slot);
343
+ return function get<K extends keyof T>(key: K): T[K] {
344
+ return ref.current[key];
345
+ };
346
+ }
347
+
348
+ function flush(fn: VoidFunction) {
349
+ queueMicrotask(() => {
350
+ flushSync(() => fn());
351
+ });
352
+ }
@@ -0,0 +1,68 @@
1
+ import { createNormalizer } from '@zag-js/types';
2
+ import type { JSX } from 'octane/jsx-runtime';
3
+
4
+ export type PropTypes = JSX.IntrinsicElements & {
5
+ element: JSX.IntrinsicElements['div'];
6
+ style: Exclude<JSX.IntrinsicElements['div']['style'], string | undefined>;
7
+ };
8
+
9
+ // Match Octane's text-entry contract (native-change-diagnostics): only these
10
+ // input types treat React-style onChange as per-edit onInput. Everything in
11
+ // NON_TEXT_INPUT_TYPES keeps native onChange; unknown types follow HTML's
12
+ // invalid-value default and are treated as text.
13
+ const TEXT_ENTRY_TYPES = new Set(['email', 'number', 'password', 'search', 'tel', 'text', 'url']);
14
+ const NON_TEXT_INPUT_TYPES = new Set([
15
+ 'button',
16
+ 'checkbox',
17
+ 'color',
18
+ 'date',
19
+ 'datetime-local',
20
+ 'file',
21
+ 'hidden',
22
+ 'image',
23
+ 'month',
24
+ 'radio',
25
+ 'range',
26
+ 'reset',
27
+ 'submit',
28
+ 'time',
29
+ 'week',
30
+ ]);
31
+
32
+ function isTextHostElement(elementKey: string, props: Record<string, unknown>): boolean {
33
+ if (elementKey === 'textarea') return true;
34
+ if (elementKey !== 'input') return false;
35
+ const type = typeof props.type === 'string' ? props.type.toLowerCase() : 'text';
36
+ if (TEXT_ENTRY_TYPES.has(type)) return true;
37
+ if (NON_TEXT_INPUT_TYPES.has(type)) return false;
38
+ return true;
39
+ }
40
+
41
+ function rewriteTextHostOnChange(
42
+ elementKey: string,
43
+ value: Record<string, unknown>,
44
+ ): Record<string, unknown> {
45
+ if (!isTextHostElement(elementKey, value)) return value;
46
+ if (typeof value.onChange !== 'function' || value.onInput !== undefined) return value;
47
+ const { onChange, ...rest } = value;
48
+ return { ...rest, onInput: onChange };
49
+ }
50
+
51
+ type ZagNormalizeProps = ReturnType<typeof createNormalizer<PropTypes>>;
52
+
53
+ function createOctaneNormalizer(): ZagNormalizeProps {
54
+ return new Proxy({} as ZagNormalizeProps, {
55
+ get(_target, key) {
56
+ if (key === 'style') {
57
+ return function styleProps(props: PropTypes['style']) {
58
+ return props;
59
+ };
60
+ }
61
+ return function normalizeElementProps(value: Record<string, unknown>) {
62
+ return rewriteTextHostOnChange(String(key), value);
63
+ };
64
+ },
65
+ });
66
+ }
67
+
68
+ export const normalizeProps = createOctaneNormalizer();
package/src/portal.ts ADDED
@@ -0,0 +1,49 @@
1
+ import {
2
+ Children,
3
+ createElement,
4
+ createPortal,
5
+ Fragment,
6
+ isChildrenBlock,
7
+ useLayoutEffect,
8
+ useState,
9
+ } from 'octane';
10
+ import type { OctaneNode } from 'octane';
11
+ import { subSlot } from './internal';
12
+
13
+ export interface PortalProps {
14
+ disabled?: boolean | undefined;
15
+ container?: { current: HTMLElement | null } | undefined;
16
+ getRootNode?: (() => ShadowRoot | Document | Node) | undefined;
17
+ children?: OctaneNode;
18
+ }
19
+
20
+ const PORTAL_SLOT = Symbol.for('@octanejs/zag/Portal');
21
+
22
+ export const Portal = (props: PortalProps) => {
23
+ const { children, container, disabled, getRootNode } = props;
24
+ const [mounted, setMounted] = useState(false, subSlot(PORTAL_SLOT, 'mounted'));
25
+
26
+ useLayoutEffect(
27
+ () => {
28
+ setMounted(true);
29
+ },
30
+ [],
31
+ subSlot(PORTAL_SLOT, 'mount-effect'),
32
+ );
33
+
34
+ if (!mounted || typeof window === 'undefined' || disabled) {
35
+ return createElement(Fragment, null, children);
36
+ }
37
+
38
+ const rootNode = getRootNode?.();
39
+ const doc =
40
+ rootNode?.nodeType === Node.DOCUMENT_NODE
41
+ ? (rootNode as Document)
42
+ : (rootNode?.ownerDocument ?? document);
43
+ const mountNode = container?.current ?? doc.body;
44
+ if (isChildrenBlock(children)) {
45
+ return createPortal(children, mountNode);
46
+ }
47
+ const portals = Children.map(children, (child) => createPortal(child, mountNode));
48
+ return createElement(Fragment, null, portals);
49
+ };
package/src/refs.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { useRef } from 'octane';
2
+
3
+ import { subSlot } from './internal';
4
+
5
+ export function useRefs<T>(refs: T, slot?: symbol) {
6
+ const ref = useRef(refs, subSlot(slot, 'refs'));
7
+ return {
8
+ get<K extends keyof T>(key: K): T[K] {
9
+ return ref.current[key];
10
+ },
11
+ set<K extends keyof T>(key: K, value: T[K]) {
12
+ ref.current[key] = value;
13
+ },
14
+ };
15
+ }
package/src/track.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef } from 'octane';
2
+
3
+ import { subSlot } from './internal';
4
+
5
+ export const useTrack = (deps: any[], effect: VoidFunction, slot?: symbol) => {
6
+ const render = useRef(false, subSlot(slot, 'render'));
7
+ const called = useRef(false, subSlot(slot, 'called'));
8
+
9
+ useEffect(
10
+ () => {
11
+ const run = render.current && called.current;
12
+ if (run) return effect();
13
+ called.current = true;
14
+ },
15
+ [
16
+ ...(deps ?? []).map((dependency) =>
17
+ typeof dependency === 'function' ? dependency() : dependency,
18
+ ),
19
+ ],
20
+ subSlot(slot, 'tracked-effect'),
21
+ );
22
+
23
+ useEffect(
24
+ () => {
25
+ render.current = true;
26
+ return () => {
27
+ render.current = false;
28
+ };
29
+ },
30
+ [],
31
+ subSlot(slot, 'mount-effect'),
32
+ );
33
+ };
@@ -0,0 +1,4 @@
1
+ import { useEffect, useLayoutEffect } from 'octane';
2
+
3
+ export const useSafeLayoutEffect =
4
+ typeof globalThis.document !== 'undefined' ? useLayoutEffect : useEffect;