Repository navigation
Conversation
Add @dunky.dev/state-machine-svelte, a thin runes-based edge layer
mirroring the React package:
- useMachine: build-once bridge + lifecycle + prop-scoped substrate
effects, returning a reactive { api, machine }. Takes props as a
getter (() => props) since Svelte props are reactive bindings.
- useSelector: fine-grained leaf subscription returning { current }.
- normalize/mergeProps: Svelte DOM idiom (lowercase on* event props,
class/style string merge).
The package ships its src uncompiled (svelte export condition) so the
consumer's Svelte compiler processes the .svelte.ts runes modules; it's
excluded from the tsdown bundle for that reason. Vitest gains a scoped
svelte project (svelte plugin + svelteTesting + jsdom) so runes tests
run without disturbing the other packages.
Adds docs (libs/svelte.mdx + sidebar entry) and a changeset.
Co-authored-by: Claude <noreply@anthropic.com>
A fourth renderer for the shared @sandbox/cmdk-core command palette, driven by the Svelte binding. Unlike the React/OpenTUI/Native apps — which all render through a React reconciler and share React's useMachine — the Svelte app brings its own runes-based useMachine from @dunky.dev/state-machine-svelte, yet runs the exact same machine and connect() unchanged. Same behavior, different framework. - Vite + vite-plugin-svelte, aliasing every @dunky.dev/* package (and the svelte binding's .svelte.ts runes source) to src for a live, no-build workspace run. - app.svelte / command-palette.svelte port the React demo to Svelte 5: useMachine over the shared machine, normalize → lowercase DOM props, the ⌘K shortcut as a ComponentEffect, input-focus as an $effect. - Updates sandbox/README.md to four substrates. Co-authored-by: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: Claude <noreply@anthropic.com>
Brings the branch up to main (renamed targets, per-package tsconfigs, state-machine-dom, TypeScript 7, the opentui target, the website redesign). Conflicts resolved by taking main's side and re-adding the svelte entries: the tsconfig path, the svelte vitest project next to the solid one, the Integrations sidebar entry after Solid, and the sandbox README lines. The lockfile is main's, regenerated by pnpm install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… conventions Renames the package to @dunky.dev/svelte-state-machine (the #43 convention) across the manifest, tsconfig path, sandbox, docs, and changeset, and conforms the manifest to main: exact workspace:* pins for internal deps (#59) and a header-only CHANGELOG, as the solid target was introduced (#32). Drops the ComponentEffects alias (#49): useMachine takes a plain ComponentEffect[] like every other target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mirrors packages/solid: the svelte project (vite-plugin-svelte 7.3.1 + svelteTesting() from @testing-library/svelte 5.4.2) lives with its package and is referenced from the root config, so the root carries no Svelte dependencies. node by default; DOM tests opt into jsdom per file. The package joins tsconfig.build.json, so `pnpm typecheck` covers its TypeScript. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
normalize kept its own copies of the aria- projection, the payload adapters, and AnyEvent, and its adapters handed out the event's preventDefault detached, so calling payload.preventDefault() threw Illegal invocation (main fixed this once, in state-machine-dom). It now reuses the shared maps and adapters and keeps only what is Svelte's own. Svelte 5 event props are the DOM's handler attribute names, lowercase and case-sensitive, which is exactly the shared React-cased map lowercased, so the map is derived rather than restated; onValueChange -> oninput and onDoublePress -> ondblclick are the two named keys. The maps are vocabulary-typed (HandlerTargets/AttrTargets) and run through the shared accounting fixture, and a jsdom suite fires every mapped handler from the real DOM event a user action produces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mergeProps reimplemented the handler composition and dropped the base's camelCase composition. It now runs the agnostic mergeProps and composes Svelte's lowercase event props (onclick, onkeydown) with the shared composeHandlers, so the defaultPrevented veto is written once. Extending the composition on the Svelte side keeps the published base untouched for every other target. class merges as [consumer, library] whatever its shape: Svelte 5.16+ resolves strings, arrays, and objects through clsx, so the consumer's array or object class is no longer dropped. Overlapping style strings join with the library last, so its declarations win. Typed like the react and solid mergeProps, so Svelte's element prop types pass in and out cast-free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ine faithfully useMachine: - the documented `() => props` usage never reached the snapshot: the connector stored the live props proxy and the setProps effect read no keys, so it never re-ran and Object.is short-circuited anyway. It now seeds a plain copy and spreads the props inside the effect, so every key is tracked and setProps value-dedups. - the api is the connector's own memoized object ($state.raw), not a deep-proxied copy with a new identity. - the connector subscription is a pre-effect: it runs synchronously at init (never on the server), so a send from a child's mount effect - which runs before the parent's - reaches the api instead of leaving it stale. - a ComponentEffect re-runs only when a named dep's value changes: one $derived per dep dedups it whatever the props getter reads (including getters that build their own object), and the body runs untracked so a prop it merely reads is never a hidden dependency. - lifecycle matches React's measured order: start, then the component effects, after mount; stop, then their cleanups, on destroy. Effects never run under svelte/server, pinned by an SSR suite. useSelector: - the selected value is held raw, never proxied, with the same init-time subscription; the props its selector closes over re-seed the value and the dedup baseline, and notify-time evaluations run untracked so a send inside an effect adds no dependencies to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with svelte-check The PR shipped raw TypeScript src behind a svelte export condition, so a consumer's tsc and svelte-check type-checked our sources under their own compiler options. tsdown can't take over: bundling folds the .svelte.ts runes modules into a plain index.js the Svelte compiler never sees, and a runes-free build has no public effect primitive that tracks props. @sveltejs/package 3 is the official convention: it strips the types, keeps each runes module as a .svelte.js file for the consumer's compiler, and emits a .d.ts per module. The published exports resolve through the types and svelte conditions only, so a tool that would load uncompiled runes fails loudly instead. The root build runs it after tsdown (so changeset:publish builds it too) and publint checks the packed manifest. Module specifiers are explicit .js: the output ships unbundled. state-machine-bindings becomes a dependency because the per-module declarations reference its types. svelte-package 3 and svelte-check 4.7 need the classic TypeScript JS API, which TypeScript 7 no longer ships, so the package pins a local typescript@^6. pnpm typecheck now runs svelte-check after tsc -b, which type-checks the .svelte test harnesses tsc can only see as a shim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The demo now renders through the stylesheet the React and Solid apps share (same classes, same copy, the Palette typo fixed), on the latest toolchain: svelte 5.57.1, vite-plugin-svelte 7.3.1, vite 8.3.2. It dedupes svelte like the solid sandbox dedupes solid-js, since the aliased binding would otherwise load its own runtime, and drops the svelte.config.js that configured nothing. The active row uses Svelte's class array, and the two pointer-only backdrop handlers say why they skip the a11y key-handler rule rather than carrying no-op handlers. lint-staged now runs oxlint on .svelte files, which lints their script blocks; oxfmt has no .svelte support yet, so they are formatted by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The peer range was `^5`. mergeProps hands Svelte a `[consumer, library]` class, which renders only since 5.16 resolved class arrays and objects through clsx; 5.33.5-5.34.4 kept a spread's first non-delegated event handler (sveltejs/svelte#15961, fixed in #16180), so a re-rendered part's onpointerenter/onfocus would call a stale connect() closure. Every other API used ($state.raw, $effect.pre, $derived, untrack) is 5.0. The range is the measured one: this suite passes on 5.16.0, 5.33.4, 5.34.5, and the last patch of every minor from 5.16 to 5.57, and fails on 5.15 (class) and 5.33.5-5.34.4 (stale handler). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package README now mirrors the solid one: a quick start, the bridge reference (why props go in as a getter, the raw snapshot, the effect order), the measured version support, and what a consumer's build needs to compile the shipped runes modules. The website page follows the current react/solid structure with Svelte 5 runes examples. AGENTS.md, ARCHITECTURE.md, the ACCESSIBILITY.md hidden mapping tree, the effects page, CONTRIBUTING's sandbox commands (solid was missing too), and the React sandbox's stylesheet note now name svelte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The demo resolves state-machine-dom and -utils through its Vite aliases into the binding's source, so listing them only added two knip findings. CONTRIBUTING's typecheck row now mentions the svelte-check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pure suite re-asserted the shared aria- projection value by value, which state-machine-dom pins once and the vocabulary-accounting suite already accounts for here. It now covers only Svelte's own translation (lowercase event props, oninput/ondblclick, tabindex, the adapter wrapping) and the loop's pass-through rules; the DOM suite still pins how ARIA booleans render. Also scopes the PR to the svelte target: the React sandbox's stylesheet note and the Solid line in CONTRIBUTING are left to their own targets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… children Core notifies synchronously inside send(), so everything that woke on it ran in the tracking scope of whichever effect sent. A reaction callback that read or wrote state (onOpenChange counting, logging) made that effect invalidate itself until effect_update_depth_exceeded, and connect()'s reads made a child's mount effect re-send on unrelated state. The machine's send is now wrapped in untrack before the connector captures it. The api is a $derived over a write-only version signal and the connector's lazy snapshot: connect() runs after a transition settles (it used to run mid-transition and could throw out of send()), once per flush and only when read - 4 -> 1 calls for a 4-patch transition, 11 -> 1 for a same-tick burst - and the server renders the snapshot a send during script init produced. The stop teardown moved to the pre-effect created before the template, so the machine stops before child components tear down, as React's unmount does; a child's cleanup send no longer fires reactions. The props getter is read through one $derived (6 -> 1 calls at mount). useSelector keeps one subscription and one dedup baseline for machine and prop changes, so isEqual now also gates a prop-driven re-seed (equal object selections no longer wake readers), with 3 -> 2 evaluations at mount and no re-subscribe on a prop change. It also takes the machine through a getter, re-subscribing when the machine changes and avoiding Svelte's state_referenced_locally warning. The suites drop three tests implied by others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…handler map mergeProps ran the shared base and then a second, regex-gated pass for Svelte's lowercase event props. It is one pass now (3.5 -> 6.7 M ops/s on a typical part), composing every on* key through the shared composeHandlers so the veto stays written once; extending the published base instead would leave the packed package depending on a utils release that doesn't exist yet. Overlapping string classes join into a string again, since a consumer component interpolating `class` would render an array as `a,b`; other shapes still merge as [consumer, library]. A nullish library class or style no longer erases the consumer's. normalize and mergeProps carry symbol keys, so a Svelte attachment on either side still attaches. Non-vocabulary keys still pass through untouched, a camelCase onMouseDown included: only the vocabulary is translated, and that record lives in the maps. HANDLER_MAP lists all seventeen handlers as a Record<HandlerKey, string>: greppable, and a new vocabulary handler fails to compile until Svelte maps it. That also leaves state-machine-dom untouched, so its comments no longer need a casing contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the tree The focus effect read view.api.open directly, and view.api is replaced on every machine change, so it re-focused the input on each keystroke, arrow, and hover (5 calls across 5 keystrokes and an arrow, now 0). It reads a $derived slice that changes only on open/close. The sandbox tree used Unicode arrows; AGENTS.md asks for plain ASCII in diagrams, so the whole tree now uses ->. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root typecheck hard-coded the svelte package; it now runs tsc -b and then any package's own typecheck script (only svelte's svelte-check today), so the package scripts are the contract. The svelte tsconfig's types override was redundant: its modules import svelte, which brings the rune and *.svelte declarations along. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rules The README, the website page, and the changeset now say what the bridge does after the review fixes: connect() runs lazily once a transition settles, sends never lend dependencies to the effect that sent, the machine stops before children tear down, and props compare shallowly (pass a new reference). They retract the claim that view.api changes only on real changes - it is a fresh object per machine change - and teach the two effect rules: read $derived slices, and act through untrack or view.machine.send. The samples spread `rest` rather than the whole props onto an element, and pass a prop machine to useSelector as a getter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nit sends Before Svelte 5.52, a server-compiled $derived in a module captured its value when declared, so a send during script init (`if (autoOpen) view.machine.send(...)`) server-rendered the stale snapshot and mismatched hydration on 5.16-5.51. The $derived bought nothing: the connector already memoizes and every notify yields a new snapshot. The api getter now reads the version signal and returns the connector's snapshot directly - just as lazy on the client, always current on the server, on every supported version. Also pins the shallow-props contract the docs describe: a replaced prop reaches the api, an in-place mutation does not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
svelte-check flagged rerender({ box }) against a prop the harness never
declared; the untyped mount helper had hidden it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ject.is deps Not every notification passes through the wrapped send: a core effect's setContext and an action's own send (captured before the wrap) reached reaction callbacks inside the consumer effect that caused them, so their reads became that effect's dependencies. Each reaction's selector and callback now runs untracked where the connector consumes it, whatever notified; the send wrapper stays for guards, actions, and the consumer's own machine listeners. A read of view.api from inside a send - a reaction's callback, a selector over the api - ran connect() after setState but before the entry actions, so a connect relying on an entry-action invariant threw out of send() and left the transition half-applied. While a send is in flight the getter returns the last settled snapshot; a selector over the api catches up in the next flush because the getter tracks the version signal. after() timers enqueue without the wrapper, so a core "settled" hook remains the complete fix. ComponentEffect deps now compare with Object.is, as React's dep array does ($derived uses ===): a NaN dep no longer re-runs on unrelated prop changes, and 0 -> -0 re-runs. The init copy of the props moved into untrack(), which drops the state_referenced_locally warning every compile of the module printed. The connect-reads test passed with or without the send wrapper since connect went lazy; it now pins the wrapper through a consumer's own machine listener. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er machine, no throws into send
On the server pre-effects never run, so `current` stayed the value
captured at creation: a send later in the script showed in view.api but
not in the selection, and SSR rendered the wrong branch (silently
replaced on hydration). Until the client subscription is live, the
getter reads the selector.
A machine getter re-subscribed on every unrelated prop change when the
props share one signal (a parent's {...spread}); the machine now goes
through a $derived, so only a different machine re-subscribes.
A reader its parent drops on this very change still hears the change's
notification, and a selector reading an index that is gone threw out of
the sender's send(), aborting the notification and leaving the machine
half-applied. The notification-time evaluation now catches and retries
in the next flush: a destroyed reader is skipped there, a mounted one
surfaces the error in Svelte's flush instead of in send().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ame merge rules as they are The pure suite asserted each handler's prop name, which the DOM suite proves on a real element from an independent table of DOM event types; it keeps what the DOM can't see (unwrapped handlers, tabindex, the pass-through rules) and the vocabulary accounting. The merge-props tests no longer say "inherits from the agnostic base", which mergeProps stopped calling, and pin the documented rule that a nullish library handler wins while a nullish class or style keeps the consumer's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… must inline Every Svelte sample in the README and on the website now compiles under strict TypeScript with svelte-check --fail-on-warnings against the packed package: typed connects, setup.as<Context, Event>() (setup.infer() pins the event to never), Record parts that normalize accepts, a ./dialog module that exports what the effect and component samples import, and an Escape check that respects the config's closeOnEscape default. The effect rules say what holds: an effect that reads view.api must not also act (view.machine.send doesn't help once it has read the api), and selectors and reaction callbacks read the machine. The docs also cover the settled api mid-send, Object.is deps, the retry for a selector that throws mid-send, the nullish-handler rule, the missing `this` on composed handlers, and that the attachment test needs Svelte 5.29. "Vitest needs no server.deps.inline" held only while the adapter is in the test root's dependency graph. A harness that reaches it through other packages' sources (the UI repo's layout) loads the runes uncompiled (rune_outside_svelte, reproduced) unless it inlines the adapter; the README, the website, and the changeset now show the snippet. SSR builds need nothing extra: with no Node-resolvable export condition, Vite bundles the adapter instead of externalizing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The version signal was bumped mid-send while reads during the send got the settled snapshot, so anything re-evaluated mid-send - a $derived slice a callback reads, a parent's slice feeding a child selector's prop, $inspect, a flushSync() in a callback - cached the pre-send value against the already-bumped version and was never invalidated: a parent deriving `view.api.open` stayed false after the machine opened. The bump now waits for the outermost send to end (in `finally`, so a throwing send still releases it), and a later read refreshes. `settled` was refreshed only by a read outside a send, so with two sends in one tick a callback in the second saw the snapshot from before the first; the outermost send now refreshes it first when something changed. Transitions that don't start at view.machine.send (after timers, an action's own async send) remain unguarded, now said where the guard lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he new state The notification-time catch covered only the selector: a dropped row with an object selection and a custom isEqual still threw out of the sender's send(), half-applying the transition. isEqual now runs inside the same try. Teardown reset `live`, so every later `.current` read ran the selector against the new state - a dropped row's effect cleanup unregistering itself re-selected and the zombie error resurfaced from the flush. The subscription teardown no longer touches `live` (it only needs to start false, for the server), so a torn-down reader keeps its last value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ded transitions The README, the website, and the changeset now say that reads inside a view.machine.send see the snapshot the previous send settled to and refresh once the send ends, that flushSync() in a callback therefore renders the pre-send UI (await tick() to focus newly opened content), and that transitions starting elsewhere - after timers, an action's own async send, a watcher fed by a core effect's setContext - aren't guarded, so selectors and callbacks should read the machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Svelte 5 target:
@dunky.dev/svelte-state-machineA first-class Svelte 5 bridge for the state-machine engine, written in runes. The same machine config and
connect()run unchanged on React, Solid, Svelte, React Native, and OpenTUI.API
useMachine(createConfig, connect, effects, () => props)→{ api, machine }view.apiis read lazily:connect()runs once a send settles, only when read. Inside aview.machine.send, a read gets the snapshot the previous send settled to, and Svelte hears of the change only when the outermost send ends (soflushSync()in a callback renders the pre-send UI;await tick()to focus). Transitions that start elsewhere (aftertimers, an action's own async send) aren't guarded: selectors and callbacks should read the machine.ComponentEffectruns as its own$effect. It re-runs only when a named dep's value changes (Object.is, like React's dep array), and its body is untracked.$effectthat changed the machine, whatever notified: a send, a context change, a timer.useSelector(machine | () => machine, selector, isEqual?)→{ current }send().normalize(bindings): maps the vocabulary to Svelte's lowercase DOM event props (onclick,oninput,ondblclick),tabindex, andaria-*. Reuses@dunky.dev/state-machine-dom. Symbol-keyed attachments pass through.mergeProps(consumer, library): one pass.composeHandlers, with thedefaultPreventedveto.class/stylestrings join; other class shapes merge as[consumer, library].class/stylekeeps the consumer's. Attachments from both sides are kept.Packaging
@sveltejs/package3 buildsdistwith.svelte.jsrunes modules and a.d.tsper module. The consumer's Svelte compiler compiles them.exportsuses only thetypesandsvelteconditions.pnpm buildruns it after tsdown, and publint checks the packed manifest.typescript@^6is required, because svelte-package and svelte-check need the TypeScript JS API that TypeScript 7 does not ship.@sveltejs/vite-plugin-svelte, unless a test harness reaches the adapter only through other packages' sources. Then:test.server.deps.inline: [/@dunky\.dev\/svelte-/].svelte>=5.16.0 <5.33.5 || ^5.34.5, measured across every Svelte 5 minor. 5.16 added class clsx; 5.33.5–5.34.4 kept stale spread handlers (fix: keep spread non-delegated event handlers up to date sveltejs/svelte#16180).Tests
svelte/serverSSR, plus the shared vocabulary-accounting suite.pnpm typecheckruns svelte-check over the.svelteharnesses.svelte-check --fail-on-warningsunder strict TypeScript.Also
sandbox/svelteuses the shared cmdk machine and stylesheet on vite 8.3.2.🤖 Generated with Claude Code