From b0a15298225d2a6b86ec42cedae46bc7ee70427e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 21:40:52 -0500 Subject: [PATCH 01/47] =?UTF-8?q?=F0=9F=93=A6=20vendor=20freedom=20source?= =?UTF-8?q?=20into=20packages/freedom/src?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/AGENTS.md | 56 ++ packages/freedom/research/focus.md | 289 ++++++ packages/freedom/specs/freedom-focus-spec.md | 346 +++++++ .../freedom/specs/freedom-focus-test-plan.md | 180 ++++ packages/freedom/specs/freedom-spec.md | 908 ++++++++++++++++++ packages/freedom/specs/freedom-test-plan.md | 371 +++++++ packages/freedom/src/index.ts | 1 + packages/freedom/src/lib/dispatch.ts | 23 + packages/freedom/src/lib/focus.ts | 138 +++ packages/freedom/src/lib/freedom.ts | 121 +++ packages/freedom/src/lib/mod.ts | 38 + packages/freedom/src/lib/node.ts | 134 +++ packages/freedom/src/lib/state.ts | 13 + packages/freedom/src/lib/tree.ts | 112 +++ packages/freedom/src/lib/types.ts | 45 + packages/freedom/src/lib/validate.ts | 55 ++ packages/freedom/test/focus.test.ts | 432 +++++++++ packages/freedom/test/freedom.test.ts | 659 +++++++++++++ packages/freedom/test/helpers.ts | 35 + packages/freedom/test/suite.ts | 2 + 20 files changed, 3958 insertions(+) create mode 100644 packages/freedom/AGENTS.md create mode 100644 packages/freedom/research/focus.md create mode 100644 packages/freedom/specs/freedom-focus-spec.md create mode 100644 packages/freedom/specs/freedom-focus-test-plan.md create mode 100644 packages/freedom/specs/freedom-spec.md create mode 100644 packages/freedom/specs/freedom-test-plan.md create mode 100644 packages/freedom/src/index.ts create mode 100644 packages/freedom/src/lib/dispatch.ts create mode 100644 packages/freedom/src/lib/focus.ts create mode 100644 packages/freedom/src/lib/freedom.ts create mode 100644 packages/freedom/src/lib/mod.ts create mode 100644 packages/freedom/src/lib/node.ts create mode 100644 packages/freedom/src/lib/state.ts create mode 100644 packages/freedom/src/lib/tree.ts create mode 100644 packages/freedom/src/lib/types.ts create mode 100644 packages/freedom/src/lib/validate.ts create mode 100644 packages/freedom/test/focus.test.ts create mode 100644 packages/freedom/test/freedom.test.ts create mode 100644 packages/freedom/test/helpers.ts create mode 100644 packages/freedom/test/suite.ts diff --git a/packages/freedom/AGENTS.md b/packages/freedom/AGENTS.md new file mode 100644 index 0000000..9a39f2f --- /dev/null +++ b/packages/freedom/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +## Spec-driven development + +- Specs in `specs/` are the source of truth. Code conforms to specs, not the + other way around. + +- Never add, remove, or change a public API (interfaces, operations, context + APIs) in code without first updating the relevant spec and getting explicit + approval from the user. This includes changes to `Node`, `Tree`, `FreedomApi`, + `DispatchApi`, `FocusApi`, and any future spec'd interfaces. + +- The workflow is: propose the spec change, wait for approval, then implement. + Do not combine spec changes with implementation in a single step. + +## Effection patterns + +- Never use `sleep(0)` for synchronization. If you need to coordinate between + tasks, use `withResolvers()` from Effection. + +- Structured concurrency handles cleanup. Do not add explicit "alive" guards, + flags, or checks for whether a scope has been destroyed. When a scope exits, + its signals are inert and its tasks are halted. + +- Do not reimplement middleware. If the spec says `createApi`, use `createApi` + and its `around()` method. Do not maintain a parallel list of handlers. + +- When delegating `[Symbol.iterator]`, bind directly: + `[Symbol.iterator]: source[Symbol.iterator]` — not a wrapper generator. + +- Effection methods are pre-bound. Do not use `.bind()` when assigning them — + just assign directly: `child.remove = task.halt`. + +## Naming conventions + +- API interfaces are named for the domain: `Freedom`, `Dispatch`. +- API constants are the interface name suffixed with `Api`: `FreedomApi`, + `DispatchApi`. +- Always export both the interface and the API constant. +- Effection resources use the `useX()` naming convention, not `createX()`. For + example: `useTree()`, not `createTree()`. + +## State + +- Do not use module-level mutable state (counters, maps, etc.). Scope state to + the resource or tree that owns it. + +## Testing + +- Tests should not need to know internal IDs. If a test needs a node reference, + get it from the tree structure or from the return value of `append`. +- Use `node.eval()` to run operations in a node's scope from tests. Do not rely + on component bodies having run by the time `createTree` returns. +- Each test file tests exactly one spec. `freedom.test.ts` tests + `freedom-spec.md`. `focus.test.ts` tests `freedom-focus-spec.md`. Do not put + tests for one spec into another spec's test file. diff --git a/packages/freedom/research/focus.md b/packages/freedom/research/focus.md new file mode 100644 index 0000000..d5073e8 --- /dev/null +++ b/packages/freedom/research/focus.md @@ -0,0 +1,289 @@ +# Focus Management Systems: Research Summary + +## Universal Principles + +Across web, TUI, game UI, and mobile paradigms, focus management converges on: + +1. **Focus is always a singleton.** Exactly one node receives input at any time. +2. **Two levels of navigation.** Nearly every system distinguishes _between + groups_ (Tab) from _within groups_ (arrows). +3. **Scoping enables composition.** Without focus scopes, every focusable + element competes in a single flat order. Scopes let subtrees manage focus + independently (dialogs, menus, panels). +4. **Restoration is universally needed.** When a focus scope closes (modal + dismiss, node removal), every system needs a strategy for where focus goes. + Dominant pattern: remember what was focused before the scope activated. +5. **Focus lifecycle is tightly coupled to component lifecycle.** When a focused + node unmounts, focus must move somewhere — this is a source of bugs in every + framework. + +--- + +## Web/DOM Focus Management + +### Native Focus Model (WHATWG HTML Spec) + +**Focusable Areas**: Regions that can receive keyboard input — elements with +non-null tabindex, natively focusable elements (buttons, inputs, links), image +map shapes, scrollable regions, and document viewports. + +**Tabindex Processing Model**: + +- Null/omitted: UA determines focusability per platform conventions +- Negative integer: Programmatically focusable via `.focus()` but excluded from + sequential tab navigation +- Zero: Focusable and sequentially navigable; position follows DOM tree order +- Positive integer: Creates explicit ordering (discouraged by spec) + +**Focus Navigation Scopes**: Each Document, shadow host, slot element, and +showing popover is a scope owner. The flattened tabindex-ordered focus +navigation scope is computed by recursively inlining child scope contents after +their scope owners. + +**Shadow DOM**: Shadow hosts are scope owners. `delegatesFocus: true` delegates +focus to the first focusable descendant. + +**Focus Events**: `focus`/`blur` do not bubble. `focusin`/`focusout` do bubble. +Events fire along the focus chain. + +**Focus Navigation Start Point** (per Sarah Higley): When a focused element is +removed from the DOM, browsers maintain a "sequential focus navigation starting +point" at the removed element's former position. Screen readers largely ignore +this mechanism. + +### ARIA Focus Management Patterns (W3C APG) + +**Roving Tabindex**: Manages focus in composite widgets (radio groups, tablists, +toolbars, trees, grids): + +1. Set `tabindex="0"` on active element, `tabindex="-1"` on siblings +2. On arrow key: old element → `-1`, new element → `0`, call `.focus()` +3. Browser automatically scrolls newly focused element into view + +**aria-activedescendant**: Keeps DOM focus on container while logically pointing +to active child. Critical limitations (per Sarah Higley): + +- Purely a screen reader construct; no effect on keyboard events or DOM focus +- No automatic scroll-into-view +- VoiceOver Safari macOS essentially ignores it +- Mobile screen readers bypass it entirely +- Best suited for comboboxes only + +**Focus traps** (modals/dialogs): Intercept Tab at boundaries and wrap. Requires +initial focus, wrapping Tab/Shift+Tab at boundaries, and focus restoration on +close. + +### Open UI `focusgroup` Proposal + +The most ambitious standardization attempt: + +- Arrow-key navigation within a declared subtree, guaranteed tab stop, automatic + last-focused memory +- Crosses shadow DOM boundaries by default; `focusgroup=none` opts out +- **Independence principle**: Nested `focusgroup` exits ancestor's group; each + element belongs to exactly one focusgroup +- Values: empty (linear), `grid`, `manual-grid`, `none` +- Modifiers: `inline`/`block` (direction), `wrap`/`flow` (boundary), `no-memory` +- Grid navigation: `row-wrap`, `col-wrap`, `row-flow`, `col-flow` + +--- + +## React Focus Management + +### React Aria FocusScope (Adobe) + +Three declarative props: + +- **`contain`**: Prevents focus from leaving scope; Tab wraps at boundaries +- **`restoreFocus`**: Stores `document.activeElement` on mount; restores on + unmount +- **`autoFocus`**: Focuses first focusable descendant on mount + +`useFocusManager()` hook: `focusNext({wrap})`, `focusPrevious({wrap})`, +`focusFirst()`, `focusLast()`. + +Implementation uses sentinel nodes as boundary markers in the DOM. + +### react-focus-lock (theKashey) + +- **No keyboard event interception**: Watches focus behavior via `focus` events, + not `keydown` +- `data-focus-lock=[group-name]` for scattered focus groups/shards (critical for + portals) +- Core `focus-lock` package is 1.5kb + +### Radix UI FocusScope + +- Tab interception only at boundaries (native browser handles mid-scope) +- `TreeWalker`-based element discovery +- Container gets `tabIndex={-1}` as fallback +- Focus scope stack: parent paused when child activates, resumes on child + unmount + +### React Issue #16009 — Rethinking Focus + +- Collecting focusable elements is O(n), measured at 850ms on Android Chrome +- Proposed ID-based focus with propagating lookups through nested scopes +- Cross-platform abstraction independent of DOM + +--- + +## Terminal/TUI Focus Management + +### Textual (Python) — Most Complete TUI System + +- **Opt-in**: `can_focus` (default `False`) and `can_focus_children` (default + `True`) +- **Focus chain**: Computed ordered list of all focusable widgets; Tab/Shift+Tab + traverse it +- **`can_focus_children=False`**: Skips children during Tab navigation + (primitive containment) +- **Programmatic**: `focus_next()`, `focus_previous()`, `widget.focus()`, + `set_focus()` +- **Events**: `Focus`, `Blur`, `DescendantFocus`, `DescendantBlur` +- **CSS**: `:focus` pseudo-class, `has_focus_within` property +- **Scroll**: `focus(scroll_visible=True)` + +### Ink (React for terminals) + +- `useFocus()` hook makes component focusable; returns `{isFocused}` +- `useFocusManager()`: `focusNext()`, `focusPrevious()`, `focus(id)` +- Focus order follows render order +- No focus scoping — focus is global + +### Bubble Tea (Go) + +- Terminal-level only: `FocusMsg`/`BlurMsg` for window focus +- Component focus is entirely DIY via `focusIndex` integer +- Community `bubbletea-nav`: flat `FocusManager` with Tab/Shift+Tab + +### Ratatui (Rust) + +- No built-in focus; community crates: `rat-focus` (FocusFlag + FocusBuilder), + `ratatui-interact` (FocusManager), `focusable` (derive macro) + +### Common TUI Pattern + +All converge on: flat ordered list of focusable widgets, Tab/Shift+Tab +navigation. None have built-in focus scoping or containment. Focus ordering is +explicit (manual index) or render/mount order. + +--- + +## Game UI / Spatial Focus + +### Unity UI Navigation + +Four modes per `Selectable`: + +- **Automatic**: Spatial proximity-based neighbor computation +- **Explicit**: Manual `selectOnUp/Down/Left/Right` per element +- **Horizontal/Vertical**: Navigation restricted to one axis +- **None**: Disables navigation + +### ImGui Navigation + +Directional algorithm: collect navigable items, score by distance + direction +alignment, select lowest score. Two NavLayers (Main, Menu). Window-level focus +with cross-window navigation support. + +### Microsoft WinUI/UWP 2D Navigation + +**XYFocusKeyboardNavigation**: `Auto`, `Enabled`, `Disabled` — creates +directional areas. + +Three strategies: + +1. **Projection**: Projects focused element's edge in navigation direction +2. **NavigationDirectionDistance**: Extends bounding rect, finds closest to + navigation axis +3. **RectilinearDistance**: Manhattan distance scoring + +**TabFocusNavigation**: `Local` (subtree tab indexes), `Once` (group tab stop), +`Cycle` (wrap within container). + +### SwiftUI Focus System + +- `@FocusState`: Property wrapper tracking focus (Boolean or Hashable enum) +- `.focused($state, equals:)`: Binds view focus to state variable +- `.defaultFocus()`: Initial focus target when scope appears +- `.focusSection()`: Groups views for directional navigation without making + section focusable + +--- + +## Focus Ordering Strategies + +| Strategy | Description | Used By | +| ------------------- | --------------------------------------------- | ----------------------------------- | +| DOM/tree order | Source order in document/tree | HTML default, Ink, Textual | +| Explicit tab order | Numeric index overrides tree order | HTML `tabindex`, WinUI | +| Roving within group | Arrows within group, Tab between groups | ARIA composites, `focusgroup` | +| Spatial/directional | Nearest element in pressed direction | WinUI, Unity, ImGui, tvOS | +| Scope-local | Tab order computed within scope | HTML focus scopes, WinUI `Local` | +| Cycle/wrap | Focus wraps at boundaries | WinUI `Cycle`, FocusScope `contain` | +| Once (group stop) | Container is single Tab stop; arrows internal | WinUI `Once`, ARIA composites | +| Render order | Component render/mount order | Ink, Bubble Tea | +| Custom/programmatic | Application logic determines order | All frameworks | + +--- + +## Focus Scoping Comparison + +| System | Scoping Mechanism | Containment | Restoration | Nesting | +| -------------------- | --------------------------- | ------------------------ | ---------------------- | --------------------------- | +| DOM/HTML | Focus navigation scopes | No built-in trap | No built-in | Hierarchical via shadow DOM | +| Open UI `focusgroup` | `focusgroup` attribute | Independent groups | Last-focused memory | Nested auto-exits parent | +| React Aria | `` | `contain` prop | `restoreFocus` prop | Parent paused | +| Radix | `` | `loop` prop | Stores `activeElement` | Focus scope stack | +| react-focus-lock | `data-focus-lock` | Focus observation | `returnFocus` prop | Focus shards | +| WinUI/UWP | `XYFocusKeyboardNavigation` | `Cycle` mode | No built-in | Inheritance | +| SwiftUI | `@FocusState` + sections | Sections guide direction | `.defaultFocus()` | Nestable | +| Textual | `can_focus_children=False` | Prevents Tab entry | No built-in | Widget tree | +| ImGui | Window flags, modals | Modal windows | No built-in | NavLayer | + +--- + +## Focus and Lifecycle + +### Focused Component Unmount + +- **DOM**: `document.activeElement` falls back to ``; navigation start + point remembered +- **React**: `onBlur` does not fire on parent when React unmounts focused child +- **React Aria**: `restoreFocus` stores previous element, restores on unmount +- **Radix**: Stores `activeElement` before activation, restores on unmount +- **Textual**: `blur()` moves focus to next available widget + +### Focus Side Effects + +- **Scroll into view**: Browser auto-scrolls on `.focus()` and roving tabindex; + NOT on `aria-activedescendant` +- **Screen reader**: Focus changes trigger announcement of focused element's + name and role +- **Focus visible**: `:focus-visible` distinguishes keyboard from mouse/touch + focus + +--- + +## Sources + +- [WHATWG HTML - The tabindex attribute](https://html.spec.whatwg.org/multipage/interaction.html) +- [W3C APG - Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) +- [Open UI focusgroup Explainer](https://open-ui.org/components/focusgroup.explainer/) +- [React Aria FocusScope](https://react-aria.adobe.com/FocusScope) +- [React Focus Management RFC](https://github.com/devongovett/rfcs-1/blob/patch-1/text/2019-focus-management.md) +- [React Issue #16009](https://github.com/facebook/react/issues/16009) +- [react-focus-lock](https://github.com/theKashey/react-focus-lock) +- [Radix UI FocusScope](https://www.npmjs.com/package/@radix-ui/react-focus-scope) +- [Sarah Higley - aria-activedescendant](https://sarahmhigley.com/writing/activedescendant/) +- [Sarah Higley - Focus navigation start point](https://sarahmhigley.com/writing/focus-navigation-start-point/) +- [Textual Input Guide](https://textual.textualize.io/guide/input/) +- [Ink](https://github.com/vadimdemedes/ink) +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) +- [ImGui Navigation](https://deepwiki.com/ocornut/imgui/2.6-input-and-navigation) +- [WinUI Focus Navigation](https://learn.microsoft.com/en-us/windows/apps/design/input/focus-navigation) +- [Unity UI Navigation](https://learn.unity.com/tutorial/ui-navigation-2019-3) +- [WICG CSS Spatial Navigation](https://github.com/WICG/spatial-navigation) +- [SwiftUI Focus - WWDC23](https://developer.apple.com/videos/play/wwdc2023/10162/) +- [Cloudscape Focus Principles](https://cloudscape.design/foundation/core-principles/accessibility/focus-management-principles/) diff --git a/packages/freedom/specs/freedom-focus-spec.md b/packages/freedom/specs/freedom-focus-spec.md new file mode 100644 index 0000000..fd5831b --- /dev/null +++ b/packages/freedom/specs/freedom-focus-spec.md @@ -0,0 +1,346 @@ +# Freedom Focus Specification + +**Version:** 0.1 — Draft\ +**Status:** Normative draft\ +**Depends on:** Freedom Specification 0.1, Effection 4.1-alpha + +--- + +## 1. Purpose + +Focus tracks which node in the tree is currently receiving input. At any point +in time, exactly one node is focused. The focus system provides structured, +linear navigation through focusable nodes and exposes focus state as an +observable node property. + +Focus is an extension to Freedom, not a core primitive. It is installed by the +root component and built entirely on Freedom's context APIs — property +operations, middleware interception, and scoped evaluation. + +--- + +## 2. Design Philosophy + +### 2.1 Minimal and linear + +This specification covers linear focus navigation only: forward, backward, and +explicit. Directional navigation (up/down/left/right), focus groups, focus +trapping, and focus restoration are deferred to future extensions. + +### 2.2 Opt-in focusability + +Nodes are not focusable by default. A node becomes focusable by calling +`yield* focusable()` in its component body. This sets the `focused` property to +`false`, adding the node to the focus chain without granting it focus. + +### 2.3 Observable via properties + +Focus state is a regular node property: `node.props.focused`. Renderers observe +focus the same way they observe any other property — by reading props after a +notification. No special subscription mechanism is needed. + +### 2.4 Built on Freedom APIs + +All focus operations use Freedom's context APIs. Focus movement uses +`node.eval()` to run `set` and `unset` in the correct node scopes. Focus cleanup +uses middleware on `remove`. No internal state is accessed directly. + +--- + +## 3. Terminology + +**Focusable node.** A node whose property bag contains the key `"focused"`. The +value is `true` (this node has focus) or `false` (this node can receive focus +but does not currently have it). A node without a `"focused"` property is not +focusable. + +**Focused node.** The unique node whose `focused` property is `true`. There is +always exactly one focused node when the focus system is installed. + +**Focus chain.** The ordered sequence of all focusable nodes in the tree, +computed by depth-first traversal. The focus chain determines the order in which +`advance` and `retreat` move focus. The chain is computed on demand — it is not +stored. + +--- + +## 4. Focus API + +### 4.1 API Definition + +The focus API is an Effection API created with `createApi`: + +``` +createApi("freedom:focus", { + *focusable(): Operation, + *advance(): Operation, + *retreat(): Operation, + *focus(node: Node): Operation, + *current(): Operation, +}) +``` + +### 4.2 Operations + +**focusable** + +F1. `focusable()` makes the current node focusable by setting its `focused` +property to `false` via `yield* set("focused", false)`. + +F2. If the current node is already focusable (the `focused` property already +exists), `focusable()` is a no-op. + +F3. `focusable()` is a `createApi` operation and can be intercepted by +middleware. A parent MAY install middleware on `focusable` to prevent a child +from becoming focusable. + +**advance** + +F4. `advance()` moves focus to the next focusable node in the focus chain (§5). + +F5. If the currently focused node is the last in the focus chain, `advance()` +wraps to the first focusable node. + +F6. Focus movement sets the old node's `focused` property to `false` and the new +node's `focused` property to `true`, using `node.eval()` to run property +operations in each node's scope (§6.2). + +F7. If the focus chain contains only one focusable node (including root), +`advance()` is a no-op. + +**retreat** + +F8. `retreat()` moves focus to the previous focusable node in the focus chain +(§5). + +F9. If the currently focused node is the first in the focus chain, `retreat()` +wraps to the last focusable node. + +F10. Focus movement follows the same mechanism as `advance` (F6). + +F11. If the focus chain contains only one focusable node, `retreat()` is a +no-op. + +**focus** + +F12. `focus(node)` sets focus to the given node explicitly. + +F13. The target node MUST be focusable (its `focused` property MUST exist). If +the target is not focusable, `focus` MUST raise an error. + +F14. Focus movement follows the same mechanism as `advance` (F6). + +F15. If the target node is already focused, `focus()` is a no-op. + +**current** + +F16. `current()` returns the currently focused node — the node whose `focused` +property is `true`. + +F17. `current()` always returns a `Node`. It never returns `undefined`, because +the root node is always focusable and the focus system guarantees exactly one +focused node exists (§7). + +F18. `current()` is a `createApi` operation and can be intercepted by +middleware. This enables future extensions (e.g., focus scoping) to override +which node is considered "current" within a subtree. + +### 4.3 Middleware Interception + +F19. Because `focusable`, `advance`, `retreat`, `focus`, and `current` are +`createApi` operations, they can be intercepted by middleware installed in +ancestor scopes. + +F20. A parent component MAY install middleware on `freedom:focus` to redirect +focus, prevent focus changes, or add side effects (e.g., scroll-into-view). + +--- + +## 5. Focus Chain + +### 5.1 Computation + +FC1. The focus chain is the ordered sequence of all focusable nodes in the tree, +determined by a depth-first traversal of the tree starting from the root. + +FC2. A node is included in the focus chain if and only if its property bag +contains the key `"focused"` (with value `true` or `false`). + +FC3. The traversal visits children in their iteration order (`node.children`), +which respects any active sort function (§5.6 of the Freedom spec). This means a +parent's sort function affects focus order. + +FC4. The focus chain is computed on demand — when `advance`, `retreat`, or +`current` needs it. It is not stored or cached. This ensures the chain is always +consistent with the current tree state. + +### 5.2 Dynamic behavior + +FC5. The focus chain changes dynamically as nodes become focusable +(`focusable()`), are removed (`remove`), or are reordered (sort function +changes). + +FC6. Adding a new focusable node does not move focus. The new node joins the +chain at its tree-order position with `focused: false`. + +FC7. Changing a parent's sort function may reorder the focus chain. Focus does +not move — the focused node retains `focused: true` regardless of its new +position in the chain. + +--- + +## 6. Focus Lifecycle + +### 6.1 Installation + +FL1. Focus is installed by calling `yield* useFocus()` in the root component: + + ```ts + function* app(): Operation { + yield* useFocus(); + // ... rest of app + } + ``` + +FL2. `useFocus()` performs two actions: 1. Sets `focused: true` on the root +node, making it the initial focus target. 2. Installs middleware on +`freedom:node`'s `remove` operation to handle focused node removal (§6.3). + +FL3. `useFocus()` is an Effection resource operation. It runs within the root +node's scope, so its middleware is visible to all descendants. + +### 6.2 Focus movement + +FL4. When focus moves from node A to node B, the focus system executes: + + ```ts + yield* oldNode.eval(() => set("focused", false)); + yield* newNode.eval(() => set("focused", true)); + ``` + +FL5. Both property changes go through the `freedom:node` context API. Middleware +on `set` sees focus changes and can react to or redirect them. + +FL6. Both property changes trigger `markDirty()` via Freedom's existing +notification middleware. The changes coalesce into a single notification if they +occur within the same dispatch cycle. + +### 6.3 Focused node removal + +FL7. `useFocus()` installs middleware on the `remove` context API operation that +advances focus before a focused node is destroyed: + + ```ts + yield* FreedomApi.around({ + *remove([node], next) { + let focused = yield* node.eval(() => get("focused")); + if (focused === true) { + yield* FocusApi.operations.advance(); + } + yield* next(node); + }, + }); + ``` + +FL8. If the removed node is the only focusable node (i.e., it is the root), +`advance()` is a no-op (F7) and focus remains on root. This case should not +arise in practice because `remove` on the root is an error (C-rm3). + +FL9. Focus is advanced before `next(node)` is called, so the focus chain is +walked while the node is still in the tree. After `next(node)` completes, the +node's scope is destroyed and it leaves the focus chain naturally (its props, +including `focused`, are gone). + +--- + +## 7. Interaction with Notification + +FN1. Focus changes are property changes. They flow through Freedom's existing +notification system — no special notification mechanism is needed. + +FN2. When focus moves during a dispatch cycle (e.g., in response to a keypress), +the focus property changes coalesce with other property changes into a single +notification. + +FN3. When focus moves outside a dispatch cycle (e.g., during component +initialization via `useFocus()`), the property change emits its own notification +per Freedom's existing rules (T11). + +FN4. Renderers observe focus by reading `node.props.focused` after a +notification, the same way they read any other property. + +--- + +## 8. Invariants + +FI1. **Singleton focus.** At most one node in the tree has `focused: true` at +any time. When the focus system is installed, exactly one node has +`focused: true`. + +FI2. **Focus chain consistency.** The focus chain is always derivable from the +current tree state by depth-first traversal. No external data structure is +needed. + +FI3. **Root always focusable.** After `useFocus()` is called, the root node +always has a `focused` property (`true` or `false`). Root is always in the focus +chain and serves as the last-resort focus target. + +FI4. **Cleanup on removal.** When a focused node is removed, focus advances to +the next node in the chain before the node is destroyed. This is guaranteed by +middleware on `remove`. + +FI5. **API consistency.** All focus operations go through `createApi` and are +middleware-interceptable. Focus movement uses `node.eval()` and the +`freedom:node` context API for property changes. + +--- + +## 9. Deferred Extensions + +The following are explicitly out of scope for this version. + +### 9.1 Focus Trapping and Locking + +A focus trap prevents focus from leaving a subtree (e.g., a modal dialog). A +focus lock prevents focus from changing at all. Both require middleware on +`advance`, `retreat`, and `focus` that constrains movement. The current API is +designed to support this via middleware without changes to the core focus +operations. + +### 9.2 Focus Groups + +Focus groups partition the focus chain into segments navigated independently — +arrow keys move within a group, Tab moves between groups. This requires a second +level of navigation beyond the linear `advance`/`retreat` model. + +### 9.3 Focus Restoration + +Focus restoration remembers the previously focused node when entering a focus +scope (e.g., opening a modal) and restores it when the scope exits. This +interacts with focus trapping and the component lifecycle. + +### 9.4 Directional Navigation + +Spatial or directional navigation (up/down/left/right) requires a layout-aware +focus algorithm that scores candidate nodes by position and direction. This is +independent of linear navigation and may require renderer-specific information +(e.g., bounding rectangles). + +--- + +## 10. Implementation Files + +**`lib/focus.ts`** — `createApi("freedom:focus", ...)`. `focusable`, `advance`, +`retreat`, `focus`, `current` operations. `useFocus()` installation resource. + +**`lib/mod.ts`** — Re-exports focus API and operations. + +--- + +## 11. Dependencies + +Freedom Focus depends on: + +- Freedom Specification 0.1 — `freedom:node` context API (`get`, `set`, + `remove`), `Node`, `Tree`, `node.eval()` +- Effection 4.1-alpha — `createApi`, `Operation`, `resource` diff --git a/packages/freedom/specs/freedom-focus-test-plan.md b/packages/freedom/specs/freedom-focus-test-plan.md new file mode 100644 index 0000000..fd8f11b --- /dev/null +++ b/packages/freedom/specs/freedom-focus-test-plan.md @@ -0,0 +1,180 @@ +# Freedom Focus — Conformance Test Plan + +**Tests:** Freedom Focus Specification + +--- + +## 1. Test Plan Scope + +### 1.1 What This Plan Covers + +This test plan defines conformance criteria for the Freedom Focus extension as +specified in the Freedom Focus Specification. It covers: + +- Installation via `useFocus()` (§6.1) +- `focusable()` operation (§4.2) +- Focus chain computation via depth-first traversal (§5) +- `advance()` and `retreat()` with wrapping (§4.2) +- `focus(node)` explicit focus (§4.2) +- `current()` query (§4.2) +- Focused node removal (§6.3) +- Notification interaction (§7) +- All invariants (§8) + +It also covers the core spec additions required by focus: + +- `get(key)` operation (Freedom Spec §6.2) +- `remove(node)` operation (Freedom Spec §6.2) + +### 1.2 What This Plan Does Not Cover + +- Focus trapping and locking (§9.1) +- Focus groups (§9.2) +- Focus restoration (§9.3) +- Directional navigation (§9.4) +- Focus middleware interception (tested by applications) + +### 1.3 Tiers + +**Core:** Tests that every conforming implementation MUST pass. + +**Extended:** Tests for edge cases and robustness. + +--- + +## 2. Core Spec Additions + +### 2.1 Core: get operation + +GA1. `set("k", 42)` then `get("k")` — returns `42`. GA2. `get("missing")` — +returns `undefined`. GA3. `get` middleware can intercept reads. + +### 2.2 Core: remove operation + +RA1. `remove(child)` destroys the child node. The child no longer appears in +parent's `children`. RA2. `remove(root)` raises an error. RA3. `remove` +middleware can intercept removal. RA4. `remove` middleware runs before teardown +— the node is still in the tree when middleware executes. + +--- + +## 3. Installation + +### 3.1 Core: useFocus + +FI1. After `useFocus()`, root has `focused: true`. FI2. After `useFocus()`, root +is in the focus chain. FI3. `current()` returns root after installation. + +--- + +## 4. focusable() + +### 4.1 Core: Opt-in + +FF1. `focusable()` sets `focused: false` on the current node. FF2. After +`focusable()`, the node appears in the focus chain. FF3. `focusable()` on an +already-focusable node is a no-op. FF4. A node that never calls `focusable()` is +not in the focus chain. + +--- + +## 5. Focus Chain + +### 5.1 Core: Depth-First Order + +FC1. Root with children A, B, C — all focusable. Chain order is root, A, B, C. +FC2. Root with child A (focusable), A has child A1 (focusable), root has child B +(focusable). Chain order is root, A, A1, B. FC3. Non-focusable nodes are +skipped. Root (focusable), A (not focusable), B (focusable). Chain is root, B. + +### 5.2 Core: Sort Function Interaction + +FC4. Parent has sort function reversing children. Children A, B, C appended in +order but sorted as C, B, A. Focus chain reflects sorted order. + +--- + +## 6. advance() + +### 6.1 Core: Forward Navigation + +FA1. Focus on root, advance → focus moves to first focusable child. FA2. Focus +on child A, advance → focus moves to child B. FA3. Old node gets +`focused: false`, new node gets `focused: true`. + +### 6.2 Core: Wrapping + +FA4. Focus on last focusable node, advance → wraps to first focusable node +(root). + +### 6.3 Core: Single Node + +FA5. Only one focusable node (root). `advance()` is a no-op. Root stays focused. + +--- + +## 7. retreat() + +### 7.1 Core: Backward Navigation + +FR1. Focus on child B, retreat → focus moves to child A. FR2. Focus on first +focusable child, retreat → wraps to last focusable node. + +### 7.2 Core: Single Node + +FR3. Only one focusable node. `retreat()` is a no-op. + +--- + +## 8. focus(node) + +### 8.1 Core: Explicit Focus + +FE1. `focus(childB)` — childB gets `focused: true`, old focused node gets +`focused: false`. FE2. `focus(node)` on a non-focusable node raises an error. +FE3. `focus(node)` on the already-focused node is a no-op. + +--- + +## 9. current() + +### 9.1 Core: Query + +CU1. After `useFocus()`, `current()` returns root. CU2. After `advance()`, +`current()` returns the new focused node. CU3. `current()` never returns +`undefined`. + +--- + +## 10. Focused Node Removal + +### 10.1 Core: Advance on Remove + +FR1. Remove the focused node. Focus advances to the next node in the chain. FR2. +Remove the last focused node in the chain. Focus wraps to the first. FR3. After +removal, the removed node is gone from the focus chain. + +### 10.2 Extended: Edge Cases + +FR4. Remove a non-focused focusable node. Focus does not move. FR5. Remove the +only non-root focusable node. Focus returns to root. + +--- + +## 11. Notification + +### 11.1 Core: Focus Changes Notify + +FN1. `advance()` triggers a tree notification (focus property changes). FN2. +`focusable()` triggers a tree notification (property set). FN3. Multiple focus +operations in one dispatch cycle coalesce into a single notification. + +--- + +## 12. Explicit Non-Tests + +The following are explicitly NOT tested: + +NT1. Focus trapping / containment (§9.1). NT2. Focus group navigation (§9.2). +NT3. Focus restoration on scope exit (§9.3). NT4. Directional / spatial +navigation (§9.4). NT5. Focus middleware interception patterns (app-level). diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md new file mode 100644 index 0000000..539f1de --- /dev/null +++ b/packages/freedom/specs/freedom-spec.md @@ -0,0 +1,908 @@ +# Freedom Specification + +**Version:** 0.1 — Draft\ +**Status:** Normative draft\ +**Depends on:** Effection 4.1-alpha (`createApi`, `createContext`, `Signal`, +`Stream`, `resource`, `spawn`, `Result`) + +--- + +## 1. Purpose + +Freedom ("free DOM") is a general-purpose abstract component tree built on +Effection's structured concurrency. It maintains a tree of long-lived, stateful +component nodes where each node is an Effection resource with a scope, a +property bag, and ordered children. + +The tree is a **bidirectional firehose**: + +- **Input:** A synchronous `dispatch(event)` entry point accepts events of any + shape at any rate. +- **Output:** A `Stream` emits a notification after every mutation cycle, + signaling renderers to walk the tree and rebuild output. + +Freedom is renderer-agnostic. It has no opinion about what properties mean, what +events look like, or how output is produced. Renderers, event vocabularies, and +display systems are consumers that operate on the tree from the outside. + +--- + +## 2. Design Philosophy + +### 2.1 Renderer independence + +Freedom provides structure, state, events, and change notification. It does not +provide layout, rendering, styling, or any visual vocabulary. A clayterm +renderer reads `node.props["clay"]`. A DOM renderer reads `node.props["html"]`. +Freedom does not know or care. + +### 2.2 Event agnosticism + +Freedom defines exactly one event operation: `dispatch(event: unknown)`. It does +not define keyboard events, mouse events, or any other vocabulary. Applications +and framework layers install middleware at the root scope to demux raw events +into their own typed APIs. This allows Freedom to serve DOM applications, +terminal applications, game engines, or any other event source without +modification. + +### 2.3 Operations over methods + +Node mutations (`set`, `update`) and structural operations (`append`, `remove`, +`sort`) are Effection context API operations, not methods on the Node object. +This makes them interceptable by middleware — a parent scope can wrap `set` to +validate, transform, log, or reject property changes in its subtree. The +component signature is `() => Operation`; everything is accessed through +the ambient scope. + +### 2.4 Orthogonal concerns + +Identity, properties, and child ordering are fully independent: + +- **Identity** is intrinsic (object reference) with a framework-assigned unique + `id` for external use. +- **Properties** are a JSON-like bag with conventional namespacing. +- **Ordering** is a separate axis: insertion order by default, or a custom sort + function owned by the parent. + +### 2.5 Immediate-mode output + +The output stream emits `void` — a pure notification that something changed. +Renderers walk the tree and read properties to produce output. This matches +immediate-mode rendering patterns (e.g., clayterm rebuilds `Op[]` every frame). +The JSON-like property constraint does not foreclose on richer change records in +the future. + +### 2.6 Read-only Node surface + +The `Node` object's data fields — `id`, `name`, `props`, `children`, `parent` — +are for reading only. All property and structural mutations go through the +`freedom:node` context API, which makes every mutation interceptable by +middleware. `Node` also exposes `eval()` for scoped operation execution and +`remove()` for lifecycle management. `remove()` is both a convenience method on +`Node` and a context API operation — the method delegates to the operation, +ensuring middleware always participates in teardown. + +--- + +## 3. Terminology + +**Tree.** The root resource that owns all component nodes. Externally it is an +event sink (`dispatch`) and a change source (`Stream`). Internally it is +an Effection scope that contains the root node, the event loop, and the +notification mechanism. + +**Node.** A long-lived Effection resource within the tree. Each node has a +framework-assigned unique `id`, an optional name, a property bag, an ordered +list of children, and a parent (except the root). A node's in-memory identity is +its object reference; its externalizable identity is its `id`. The Node's data +fields are read-only — all property mutations go through the context API. Each +node maintains an eval loop that accepts operations via `node.eval()`, enabling +scoped execution. `node.remove()` delegates to the `remove` context API +operation, which halts the node's scope and tears down all descendants. +Middleware on `remove` participates in teardown. + +**Component.** A generator function of type `() => Operation` that runs +within a node's Effection scope for the node's entire lifetime. Components +access node operations and event middleware through the ambient scope. A +component either does its initialization work and returns, or enters an infinite +loop that reacts to changes. In either case, the node remains alive — the node's +scope outlives the component generator. + +**Property bag.** A `Record` on each node. Properties are the +node's state AND its renderable description — they are the same thing. +Properties are namespaced by convention (e.g., `"clay"`, `"aria"`). + +**JsonValue.** The set of permissible property values: +`string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }`. +No `undefined`. Properties are removed with `unset()`. + +**Dispatch.** The synchronous entry point for events. `tree.dispatch(event)` +pushes an event into the tree's internal Signal. The event is then processed +operationally through middleware. + +**Middleware.** An Effection `createApi` middleware function that intercepts an +operation. In Freedom, middleware is used for both event handling +(`dispatch.around(...)`) and property mutation interception +(`node.around(...)`). + +**Demux.** Application-level middleware installed at the root scope that routes +raw `dispatch` events into typed, application-specific APIs. Demuxing is NOT a +Freedom concept — it is a pattern that applications implement. + +**Notification.** A `void` emission on the tree's output stream indicating that +the tree may have changed. At most one notification is emitted per dispatch +cycle. A dispatch cycle that produces no property or structural changes MUST NOT +emit a notification. + +--- + +## 4. JsonValue + +### 4.1 Definition + +``` +JsonValue = string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue } +``` + +### 4.2 Constraints + +J1. `undefined` is NOT a valid JsonValue. Implementations MUST reject +`undefined` in `set()` and `update()`. + +J2. `NaN`, `Infinity`, and `-Infinity` are NOT valid JsonValues. Implementations +MUST reject them. + +J3. Functions, symbols, class instances, `Date`, `Map`, `Set`, `RegExp`, and +other non-JSON-serializable values are NOT valid JsonValues. + +J4. Implementations SHOULD validate values at the `set()` and `update()` +boundary. Invalid values MUST NOT be stored in the property bag. + +### 4.3 Rationale + +The JsonValue constraint ensures properties are: + +- serializable (devtools, snapshots, undo/redo), +- structurally comparable (deep equality is well-defined), +- diffable (for future change record support). + +Application-level state that requires richer types (functions, class instances, +Dates) belongs in the component's generator scope as local variables, not in the +property bag. + +--- + +## 5. Node + +### 5.1 Structure + +Each node has: + +- **id** (`string`) — a unique, framework-assigned identifier. Stable for the + node's lifetime. Used by renderers and external systems to reference the node + (e.g., clayterm named elements, ARIA ids, devtools). +- **name** (`string`, optional) — a human-readable label. Defaults to `""`. Not + required to be unique. Used for debugging and optional lookup. +- **props** (`Record`) — the property bag. Initially empty. + Read-only; mutate via context API only. +- **children** (`Iterable`) — ordered child nodes. Initially empty. + Read-only. +- **parent** (`Node | undefined`) — the parent node. The root node's parent is + `undefined`. Read-only. + +```ts +interface Node { + readonly id: string; + readonly name: string; + readonly props: Record; + readonly children: Iterable; + readonly parent: Node | undefined; + readonly data: NodeData; + eval(op: () => Operation): Operation>; + remove(): Operation; +} +``` + +The Node's data fields are read-only. All property and structural mutations go +through the `freedom:node` context API (§6). `eval` is a method on Node because +it requires a specific node reference. `remove` is both a method and a context +API operation — the method delegates to the operation (§6.2), ensuring +middleware participates in teardown. `data` provides typed, symbol-keyed storage +for private, non-serializable per-node state (§5.7). + +### 5.2 Identity + +N1. A node's in-memory identity is its object reference. Two nodes are the same +node if and only if they are the same object (`===`). + +N2. Each node has a unique `id` string assigned by the framework at creation +time. The `id` is immutable — it MUST NOT change for the node's lifetime. The +`id` MUST be unique across all nodes in the tree at any given time. Two nodes +are the same node if and only if `a.id === b.id`. + +N3. The `id` is the externalizable identity. Renderers and external systems use +it to correlate nodes across render cycles, reference nodes in output (e.g., +named elements in clayterm), and track nodes in devtools. + +N4. The format of `id` is implementation-defined. It MAY be a monotonic counter, +a UUID, or any other scheme that guarantees uniqueness within the tree. + +N5. Nodes are long-lived Effection resources. A node is created once and exists +until explicitly removed or its parent scope exits. + +N6. The `name` field is informational. It does NOT participate in identity, +uniqueness, or reconciliation. + +### 5.3 Scoped Evaluation + +Each node maintains an **eval loop** — a spawned child task within the node's +scope that accepts and executes operations submitted via `node.eval()`. The eval +loop uses a channel- based "fire and await" pattern: the caller submits an +operation and waits for its result; the eval loop executes the operation in the +node's scope and resolves the result. + +N-eval1. `node.eval(op)` runs `op` within the node's Effection scope and returns +`Result`. If `op` completes successfully, the result is +`{ ok: true, value: T }`. If `op` throws, the result is `{ ok: false, error }`. + +N-eval2. Operations yielded inside `op` see the node's scope context — including +all middleware installed in the node's scope and its ancestors. + +N-eval3. The eval loop executes operations sequentially. If multiple callers +submit operations concurrently, they are processed in order. + +N-eval4. The eval loop is spawned as a child of the node's scope. It runs +alongside the component and shares the same parent scope, so middleware +installed by the component is visible to operations executed via `eval`. + +### 5.4 Lifecycle + +N7. A node is created by `append()`, which spawns a new Effection child scope +within the parent node's scope and runs the component within it. + +N8. A node is destroyed by `node.remove()`, which halts the node's spawned task. +This triggers structured teardown of the component, the eval loop, all +middleware installed in the scope, and all descendant nodes. `remove()` does not +return until teardown is complete. + +N9. When a node is destroyed, its middleware disappears automatically because +the Effection scope is destroyed. + +N10. When a parent node is destroyed, all child nodes are destroyed in reverse +creation order (LIFO), per Effection's structured concurrency guarantees. + +N11. `node.remove()` delegates to the `remove` context API operation (§6.2) via +`yield* node.eval(() => remove(node))`. The `remove` operation runs inside the +node's scope, and all middleware on `remove` participates in the teardown (outer +to inner). The innermost implementation halts the scope from within. The method +remains on `Node` for ergonomic use by parents: `yield* child.remove()`. + +N12. Calling `remove()` on the root node is an error (C-rm3). + +### 5.5 Property Bag + +N13. Properties are accessed via `node.props`, which returns a read-only +`Record`. Implementations SHOULD return a frozen or proxied +object to enforce read-only access at runtime. + +N14. Properties MUST NOT be mutated by direct assignment to the `props` object. +The ONLY way to modify properties is through the `freedom:node` context API +operations (`set`, `update`, `unset`). See §6. + +N15. The `props` object MUST reflect the latest state at all times. There is no +staleness window. + +N16. Properties are namespaced by convention. Top-level keys (e.g., `"clay"`, +`"aria"`, `"app"`) serve as namespaces. Freedom does not enforce or validate +namespaces. + +### 5.6 Children and Ordering + +N17. Children are ordered. The default ordering is **insertion order** — +children appear in the order they were appended. + +N18. A parent MAY install a **sort function** via the `sort` context API +operation. When a sort function is active, iterating `node.children` applies the +sort function to produce the ordering. Insertion order serves as tiebreaker for +children that compare equal. + +N19. A parent MAY clear the sort function via `sort(undefined)`, which reverts +to insertion order. + +N20. The sort function is applied at **read time** — when `node.children` is +iterated. There is no eager re-sorting on property changes. The sort is always +fresh because it runs against current property values at iteration time. + +N21. Installing or clearing a sort function via `sort()` MUST emit a +notification (§8), because the iteration order of children may have changed. + +### 5.7 Node Data + +Node data is typed, symbol-keyed storage for non-serializable, private values +associated with a node. It exists to give extensions and middleware a place to +store per-node state that is invisible to other extensions, renderers, and +application code. + +The property bag (§5.5) is the node's public, renderable state — constrained to +JsonValue, frozen, and observable via notifications. Node data is the +complement: private state owned by a specific extension, unconstrained in type, +and invisible to the rest of the system. Only code holding the key can read or +write the data. All NodeData operations are synchronous — no scope resolution is +needed because the node reference provides direct access to its data. + +```ts +interface NodeDataKey { + readonly symbol: symbol; + readonly defaultValue?: T; +} + +interface NodeData { + get(key: NodeDataKey): T | undefined; + set(key: NodeDataKey, value: T): void; + expect(key: NodeDataKey): T; +} + +function createNodeData( + name: string, + defaultValue?: T, +): NodeDataKey; +``` + +D1. `createNodeData(name, defaultValue?)` creates a typed data key backed by a +`Symbol(name)`. The symbol ensures true privacy — only code holding the key can +access the data. + +D2. `node.data.get(key)` returns the stored value, or `undefined` if no value +was set. + +D3. `node.data.set(key, value)` stores a value on the node under the given key. + +D4. `node.data.expect(key)` returns the stored value, or `defaultValue` if no +value was set. If neither exists, it throws an error. + +D5. Node data does NOT trigger tree notifications. It is invisible to renderers. + +The Node interface is extended: + +```ts +interface Node { + readonly id: string; + readonly name: string; + readonly props: Record; + readonly children: Iterable; + readonly parent: Node | undefined; + readonly data: NodeData; + eval(op: () => Operation): Operation>; + remove(): Operation; +} +``` + +--- + +## 6. Node Context API + +### 6.1 API Definition + +The node context API is an Effection API created with `createApi`: + +``` +createApi("freedom:node", { + *useNode(): Operation, + *get(key: string): Operation, + *set(key: string, value: JsonValue): Operation, + *update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): Operation, + *unset(key: string): Operation, + *append(name: string, component: Component): Operation, + *remove(node: Node): Operation, + *sort(fn: ((a: Node, b: Node) => number) | undefined): Operation, +}) +``` + +Note: `node.remove()` is a convenience method that delegates to the `remove` +context API operation via `yield* node.eval(() => remove(node))`. This ensures +all middleware on `remove` participates in teardown. + +### 6.2 Operations + +**useNode** + +C-node1. `useNode()` returns the current node from the ambient scope as a +`Node`. + +C-node2. `useNode()` MUST be called within a node's scope (inside a component +body, middleware, or an `eval` call). If called outside a node scope, it MUST +raise an error. + +C-node3. `useNode` is a `createApi` operation and can be intercepted by +middleware. A parent MAY install middleware on `useNode` to substitute a +different node reference (e.g., for virtualization or proxying). + +**get** + +C-get1. `get(key)` returns the value of `key` in the current node's property +bag, or `undefined` if the key does not exist. + +C-get2. `get` is a `createApi` operation and can be intercepted by middleware. A +parent MAY install middleware on `get` to transform, log, or virtualize property +reads. + +**set** + +C1. `set(key, value)` stores `value` under `key` in the current node's property +bag. + +C2. `value` MUST be a valid JsonValue (§4). If not, the operation MUST raise an +error. + +C3. If `key` already exists, the previous value is replaced. + +C4. `set` triggers a tree notification (§8). + +**update** + +C5. `update(key, fn)` calls `fn` with the current value of `key` (or `undefined` +if the key does not exist) and stores the result. + +C6. The return value of `fn` MUST be a valid JsonValue (§4). If not, the +operation MUST raise an error. + +C7. `update` triggers a tree notification (§8). + +**unset** + +C8. `unset(key)` removes `key` from the current node's property bag. + +C9. If `key` does not exist, `unset` is a no-op. It MUST NOT raise an error. + +C10. `unset` triggers a tree notification (§8) only if the key existed. + +**append** + +C11. `append(name, component)` creates a new child node with the given name, +spawns an Effection child scope, and runs the component within it. + +C12. `append` returns the newly created Node. + +C13. `append` triggers a tree notification (§8). + +C14. The child node's scope is a child of the current node's scope. Middleware +installed in the child's scope inherits from the parent's scope per Effection's +context prototype chain. + +**sort** + +C19. `sort(fn)` installs a sort function on the current node. When `fn` is +defined, iterating this node's `children` applies `fn` to determine ordering. +When `fn` is `undefined`, the sort function is cleared and ordering reverts to +insertion order. + +C20. `sort` triggers a tree notification (§8) (N19). + +**remove** + +C-rm1. `remove(node)` removes the given node. The innermost (default) +implementation halts the node's scope from within, triggering structured +teardown of the component, the eval loop, all middleware installed in the scope, +and all descendant nodes. The halt mechanism lives inside the scope (e.g., a +resolver the suspended task awaits) — the node does not hold a reference to its +task. + +C-rm2. `remove(node)` is a `createApi` operation and can be intercepted by +middleware. Extensions (e.g., focus) MAY install middleware on `remove` to +perform cleanup before the node is destroyed. + +C-rm3. `remove(node)` on the root node is an error. + +C-rm4. `remove` triggers a tree notification (§8). + +### 6.3 Middleware Interception + +C21. Because `get`, `set`, `update`, `unset`, `append`, `remove`, and `sort` are +`createApi` operations, they can be intercepted by middleware installed in +ancestor scopes. + +C22. A parent component MAY install middleware on `freedom:node` to validate, +transform, log, or reject property changes in its subtree. + +C23. Middleware on `set` receives the `[key, value]` tuple and a `next` +function. It MAY modify the key or value before calling `next`, or it MAY skip +`next` to reject the mutation. + +--- + +## 7. Event Dispatch + +### 7.1 The Dispatch API + +Freedom provides a dispatch API with event dispatch and node lookup: + +``` +createApi("freedom:dispatch", { + *dispatch(event: unknown): Operation> { + return { ok: false }; + }, + *getNodeById(id: string): Operation, +}) +``` + +E1. The `dispatch` operation accepts a single argument of type `unknown`. +Freedom does not constrain, inspect, or interpret the event in any way. + +E2. The default handler (at the bottom of the middleware chain) returns +`{ ok: false }`, indicating the event was unhandled. + +E3. Middleware that handles an event MUST return `{ ok: true, value: true }`. + +E4. If middleware throws an exception, the result MUST capture it as +`{ ok: false, error }`. The dispatch loop MUST NOT crash. The tree MUST remain +alive. + +E4a. `getNodeById(id)` returns the node with the given `id`, or `undefined` if +no such node exists. This allows dispatch middleware to resolve event targets by +id and use `node.eval()` to dispatch application-level APIs in the target node's +scope. + +### 7.2 The Synchronous Bridge + +E5. `tree.dispatch(event)` is synchronous. It pushes the event into an Effection +`Signal`. + +E6. Internally, an event loop reads from the Signal and dispatches each event +through the root node's scope: +`yield* root.eval(() => dispatch.operations.dispatch(event))`. The event loop +itself MAY run anywhere — it enters the root node's scope via `eval`, so +dispatch middleware installed by the root component is always in the chain. + +E7. Events are processed sequentially — one at a time, in the order they were +dispatched. A new event is not processed until the previous event's entire +middleware chain has completed. + +### 7.3 Demuxing + +E8. Freedom does NOT provide built-in demuxing. It does not define event types, +event shapes, or event routing. + +E9. Demuxing is the root component's responsibility. The root component installs +middleware on `freedom:dispatch` that routes events to application-specific +APIs. + +E10. The root component is the first component to run, so its middleware is the +outermost layer — it sees every event before any child middleware. + +E10a. Dispatch middleware uses `getNodeById()` and `node.eval()` to route events +to specific nodes. When an event targets a particular node, the middleware +resolves the node by id, then uses `eval` to invoke an application-level API in +that node's scope. The target node's middleware on that application API +participates because `eval` runs in the node's scope. + + ```ts + // Example demux middleware (application-level, NOT Freedom) + yield* DispatchApi.around({ + *dispatch([event], next) { + if (isKeydown(event)) { + let node = yield* DispatchApi.operations.getNodeById(event.targetId); + if (node) { + yield* node.eval(() => KeyboardApi.operations.keydown(event)); + } + return { ok: true, value: true }; + } + return yield* next(event); + }, + }); + ``` + +E11. Applications define their own event APIs using `createApi`. These are NOT +part of Freedom. + +E12. Multiple demux layers MAY compose. A root breaks raw events into +`keyboard`/`mouse` APIs. A child further breaks `keyboard` into `shortcut` based +on key bindings. Each layer is middleware on an API. + +### 7.4 Event Helpers + +E13. Per-event-type helper functions are an application-level pattern, NOT part +of Freedom. They are the sanctioned way for applications and framework layers to +build ergonomic event APIs on top of Freedom's generic `dispatch` + `around()`. + +A helper wraps the raw `around()` API to hide the tuple destructuring and +provide TypeScript type narrowing: + +```ts +// Definition (in the app's event layer, NOT in Freedom) +function onkeydown( + handler: ( + event: KeydownEvent, + next: (event: KeydownEvent) => Operation, + ) => Operation, +): Operation { + return keyboard.around({ + *keydown([event], next) { + return yield* handler(event, (e) => next(e)); + }, + }); +} + +// Usage in a component +function* searchBox(): Operation { + yield* set("query", ""); + + yield* onkeydown(function* (event, next) { + if (event.key === "Enter") { + yield* update("query", () => event.text); + } else { + yield* next(event); + } + }); +} +``` + +The helper is better than raw middleware because: + +- The consumer sees `(event, next)` instead of `([event], next)`. +- TypeScript narrows `event` to `KeydownEvent` automatically. +- The `keyboard` API reference is encapsulated — the consumer does not need to + know which API object to call `around()` on. + +--- + +## 8. Tree and Notification + +### 8.1 Tree Interface + +``` +interface Tree extends Stream { + dispatch(event: unknown): void; + root: Node; +} +``` + +T1. The tree is an Effection resource. Creating it starts a root Effection scope +that owns all nodes and the event loop. + +T2. `tree.root` is the root node. It is created when the tree is created and +exists for the tree's lifetime. + +T3. `tree.dispatch(event)` is the synchronous event entry point (§7.2). + +T4. The tree is a `Stream`. Subscribing to the stream yields +notifications when the tree changes. + +### 8.2 Creation + +T5. `createTree(root: Component): Operation` creates a tree. It: 1. +Creates the root Effection scope. 2. Creates the event Signal. 3. Creates the +root node. 4. Runs the root component within the root node's scope. 5. Spawns +the event loop, which dispatches events through the root node via `root.eval()`. +6. Returns the Tree. + +T6. `createTree` returns once the root node's eval loop is ready to accept +operations. The root component runs concurrently within the root node's scope — +it MAY still be executing when `createTree` returns. The tree is usable as soon +as `eval` is operational. + +### 8.3 Notification + +T7. The tree emits a `void` notification on its output stream after each +dispatch cycle — that is, after one event has been fully processed through the +middleware chain and all resulting property mutations and structural changes +have settled. + +T8. A dispatch cycle that produces no property changes, structural changes, or +sort function changes MUST NOT emit a notification. + +T9. Multiple property mutations within a single dispatch cycle MUST coalesce +into a single notification. The renderer sees the final state, never +intermediate states. + +T10. Structural changes (append, remove) and sort changes within a dispatch +cycle also coalesce with property changes into a single notification. + +T11. Property mutations or structural changes that occur outside of a dispatch +cycle (e.g., during component initialization) MUST also emit notifications. + +T12. The notification is `void`. It carries no information about what changed. +The renderer MUST walk the tree and read properties to determine the current +state. + +### 8.4 Lifecycle + +T13. Destroying the tree (exiting its Effection scope) destroys all nodes, stops +the event loop, and closes the output stream. + +T14. Events dispatched after the tree is destroyed are silently dropped. This is +a natural consequence of structured concurrency — the event Signal is inert once +the tree's scope exits. Implementations do NOT need an explicit alive check. + +--- + +## 9. Component + +### 9.1 Signature + +``` +type Component = () => Operation; +``` + +P1. A component is a generator function that takes no arguments and returns +`Operation`. + +P2. A component runs within a node's Effection scope. It accesses node +operations and event middleware through the ambient scope via `createApi` +operations. + +P3. A component's lifetime is the node's lifetime. When the node is removed, the +component's operation is cancelled per Effection's structured concurrency. + +### 9.2 Execution Model + +P4. The node's scope outlives the component generator. When the generator +returns, the node remains alive — the eval loop (§5.3) keeps the scope active, +properties persist, and middleware remains active. + +P5. A component takes one of two forms: + + **Initialization-only:** The component sets properties, + installs middleware, appends children, and returns. The + node lives on via its eval loop. + + ```ts + function* header(): Operation { + yield* set("clay", { type: "element", bg: 0xFF0000 }); + yield* set("label", "Hello"); + yield* append("subtitle", subtitle); + } + ``` + + **Reactive:** The component spawns a long-lived task + that reacts to changes over time, then returns. + + ```ts + function* clock(): Operation { + yield* spawn(function* () { + let i = 0; + while (true) { + yield* set("tick", i++); + yield* sleep(1000); + } + }); + } + ``` + + A component MAY also run an infinite loop inline, + but this blocks the component body from returning. + The eval loop and the component run concurrently + regardless. + +P6. Steps within a component (setting properties, installing middleware, +appending children) complete in order. This means middleware and children are +established before the component enters a reactive loop or returns. + +--- + +## 10. Invariants + +I1. **Scope-tree correspondence.** The node tree and the Effection scope tree +are isomorphic. Each node's scope is a child of its parent node's scope. + +I2. **Structured teardown.** Removing a node destroys all its descendants in +reverse creation order. No orphaned nodes can exist. + +I3. **Middleware scope binding.** Middleware installed in a node's scope is +active only while that node exists. When the node is removed, its middleware is +automatically removed. + +I4. **Sequential dispatch.** Events are processed one at a time. No two events +are in-flight simultaneously. + +I5. **Consistent notification.** The tree state visible to a renderer after a +notification is complete and consistent — all mutations from the triggering +dispatch cycle have been applied. + +I6. **Property validity.** Every value in every node's property bag is a valid +JsonValue at all times. + +I7. **Ordering consistency.** `node.children` always reflects the current +ordering — either insertion order or the sort function applied at read time +against current property values. + +I8. **Node read-only plus methods.** The Node object's data fields (`id`, +`name`, `props`, `children`, `parent`) are read-only. All property reads and +mutations go through the `freedom:node` context API (`get`, `set`, `update`, +`unset`, `append`, `remove`, `sort`). `eval()` provides scoped operation +execution. `remove()` is both a convenience method and a context API operation — +the method delegates to the operation. + +--- + +## 11. Deferred Extensions + +The following are explicitly out of scope for this version. + +### 11.1 Rich Change Records + +The output stream emits `void`. A future version MAY emit structured change +records (`PropertyChange`, `StructuralChange`) with per-node, per-property +granularity. The JsonValue constraint and property bag structure are designed to +support this without API changes. + +### 11.2 JSX Transform + +A JSX transform that provides declarative sugar for `append()` calls is a +natural extension. Display properties would be set separately by each component +via the context API. JSX is a consumer of Freedom's imperative API, not a core +feature. + +### 11.3 Reconciliation + +Key-based reconciliation (matching old nodes to new declarations during +declarative re-rendering) is not supported. Nodes are managed imperatively via +`append` and `remove`. A reconciliation layer could be built on top of Freedom. + +### 11.4 Computed Properties + +Derived properties that update automatically when their dependencies change are +not supported. Components can implement this pattern manually by installing +middleware on `set`. + +### 11.5 Node Queries + +Querying the tree by property values, name patterns, or other criteria is not +provided. `getNodeById()` is provided as part of the dispatch API (§7.1) for +event targeting. For other queries, consumers walk the tree via `root` and +`children`. + +### 11.6 Event Bubbling / Capturing + +Freedom does not define event propagation phases. Middleware composition through +the scope tree provides a similar capability, but the traversal order is +determined by middleware installation order, not by a DOM-style capture/bubble +model. + +### 11.7 Explicit Index Ordering + +A built-in ordering mode based on an `index` property is not provided. If an +application wants index-based ordering, it sets an `index` property on child +nodes and provides a sort function that reads it. + +--- + +## 12. Implementation Files + +**`lib/types.ts`** — `JsonValue`, `Component`, `Node` interface, `Tree` +interface. + +**`lib/tree.ts`** — `createTree(root: Component)`. Root scope, event Signal, +output stream, event loop, notification coalescing. + +**`lib/node.ts`** — Node resource. Child scope, property bag, children list with +read-time sort. + +**`lib/dispatch.ts`** — `createApi("freedom:dispatch", ...)`. +`dispatch(event: unknown) → Result` and +`getNodeById(id: string) → Node | undefined`. Sync→operational bridge via +Signal. + +**`lib/freedom.ts`** — `createApi("freedom:node", ...)`. `useNode`, `get`, +`set`, `update`, `unset`, `append`, `remove`, `sort` operations. + +**`lib/mod.ts`** — Re-exports. + +--- + +## 13. Dependencies + +Freedom depends on Effection 4.1-alpha for: + +- `createApi` — context-scoped API with middleware +- `createContext` — scoped state +- `Signal` — synchronous write, operational read +- `Stream` — async iteration protocol +- `resource` — long-lived scoped values +- `spawn` — concurrent child tasks +- `Result` — success/failure envelope +- `Operation` — the base computation type diff --git a/packages/freedom/specs/freedom-test-plan.md b/packages/freedom/specs/freedom-test-plan.md new file mode 100644 index 0000000..70bd203 --- /dev/null +++ b/packages/freedom/specs/freedom-test-plan.md @@ -0,0 +1,371 @@ +# Freedom — Conformance Test Plan + +**Tests:** Freedom Specification + +--- + +## 1. Test Plan Scope + +### 1.1 What This Plan Covers + +This test plan defines conformance criteria for the Freedom component tree as +specified in the Freedom Specification. It covers: + +- JsonValue validation at the `set`/`update` boundary (§4) +- Node lifecycle: creation via `append`, destruction via `remove`, structured + teardown (§5.3) +- Node identity: unique `id` assignment (§5.2) +- Property bag: `set`, `update`, `unset` operations (§6.2) +- Property bag read-only enforcement (§5.4) +- Child ordering: insertion order, custom sort at read time (§5.5) +- Sort installation/removal notification (N19) +- Node context API middleware interception (§6.3) +- Event dispatch: single API, `Result` signaling, error capture (§7.1) +- Synchronous bridge: Signal-based dispatch, sequential processing (§7.2) +- Tree creation and lifecycle (§8.2, §8.4) +- Notification coalescing, including no-change suppression (§8.3) +- Component execution model (§9) +- All invariants (§10) + +### 1.2 What This Plan Does Not Cover + +- JSX transforms (§11.2) +- Reconciliation (§11.3) +- Computed properties (§11.4) +- Node queries (§11.5) +- Event bubbling/capturing (§11.6) +- Explicit index ordering (§11.7) +- Rich change records (§11.1) +- Application-level demux patterns (§7.3) — these are tested in application test + suites, not Freedom's +- Event helper functions (§7.4) — application-level pattern + +### 1.3 Tiers + +**Core:** Tests that every conforming implementation MUST pass. A Freedom +implementation is non-conforming if any Core test fails. + +**Extended:** Tests for edge cases, boundary conditions, and robustness. +Recommended but not strictly required. + +--- + +## 2. JsonValue Validation + +### 2.1 Core: Valid Values + +JV1. `set("k", "hello")` — string accepted. JV2. `set("k", 42)` — number +accepted. JV3. `set("k", 0)` — zero accepted. JV4. `set("k", -1.5)` — negative +float accepted. JV5. `set("k", true)` — boolean accepted. JV6. `set("k", false)` +— boolean false accepted. JV7. `set("k", null)` — null accepted. JV8. +`set("k", [1, "a", true, null])` — array accepted. JV9. +`set("k", { a: 1, b: "c" })` — object accepted. JV10. +`set("k", { nested: { deep: [1, 2] } })` — nested structures accepted. JV11. +`set("k", [])` — empty array accepted. JV12. `set("k", {})` — empty object +accepted. + +### 2.2 Core: Invalid Values + +JV13. `set("k", undefined)` — MUST raise error (J1). JV14. `set("k", NaN)` — +MUST raise error (J2). JV15. `set("k", Infinity)` — MUST raise error (J2). JV16. +`set("k", -Infinity)` — MUST raise error (J2). JV17. `set("k", () => {})` — MUST +raise error (J3). JV18. `set("k", Symbol())` — MUST raise error (J3). JV19. +`set("k", new Date())` — MUST raise error (J3). JV20. `set("k", new Map())` — +MUST raise error (J3). + +### 2.3 Core: update() Validation + +JV21. `update("k", () => undefined)` — MUST raise error. The return value of the +update function is validated. JV22. `update("k", () => NaN)` — MUST raise error. +JV23. `update("k", () => 42)` — accepted. + +--- + +## 3. Node Lifecycle + +### 3.1 Core: Creation + +NL1. `append("child", component)` creates a child node. The returned node has +`name === "child"`. NL2. The child appears in `parent.children`. NL3. The +child's `parent` is the appending node. NL4. The child's `props` is initially +empty (`{}`). NL5. The child's component runs within the child's scope. NL6. +Multiple children can be appended to the same parent. + +### 3.2 Core: Node ID + +NL7. Each created node has a non-empty `id` string (N2). NL8. Two sibling nodes +have different `id` values (N2). NL9. A node's `id` does not change after +creation (N2). NL10. Nodes in different subtrees have different `id` values (N2 +— unique across the tree). NL11. The root node has an `id`. + +### 3.3 Core: Destruction + +NL12. `remove()` destroys the node. The node no longer appears in its parent's +`children`. NL13. `remove()` on a node with children destroys all descendants. +NL14. Descendant destruction occurs in reverse creation order (LIFO). NL15. +After `remove()`, middleware installed by the node's component is no longer +active. NL16. `remove()` on the root node MUST raise an error (C18). + +### 3.4 Extended: Edge Cases + +NL17. Appending to a node that is being removed — behavior is +implementation-defined but MUST NOT corrupt the tree. NL18. A component that +returns immediately (no loop) — the node remains alive (P4). + +--- + +## 4. Property Bag + +### 4.1 Core: set + +PB1. `set("a", 1)` — `node.props["a"]` is `1`. PB2. `set("a", 1)` then +`set("a", 2)` — `node.props["a"]` is `2`. PB3. `set("a", 1)` then `set("b", 2)` +— both keys present. PB4. `set("ns", { x: 1, y: 2 })` — namespaced object +stored. PB5. `node.props` reflects the update immediately after `set` completes +(N13). + +### 4.2 Core: update + +PB6. `set("n", 1)` then `update("n", (v) => v + 1)` — `node.props["n"]` is `2`. +PB7. `update("missing", (v) => v ?? 0)` — `fn` receives `undefined`, stores `0`. +PB8. `update` is atomic: `node.props` reflects the new value after `update` +completes. + +### 4.3 Core: unset + +PB9. `set("a", 1)` then `unset("a")` — `"a"` is no longer in `node.props`. PB10. +`unset("nonexistent")` — no error, no notification (C9, C10). + +### 4.4 Core: Read-Only Enforcement + +PB11. Direct assignment to `node.props["x"] = 1` MUST NOT modify the property +bag OR MUST throw (N12). Implementations SHOULD freeze or proxy `props`. + +--- + +## 5. Child Ordering + +### 5.1 Core: Insertion Order + +CO1. Append A, B, C. `children` yields A, B, C. CO2. Append A, B, C. Remove B. +`children` yields A, C. CO3. Append A, B. Remove A. `children` yields B. + +### 5.2 Core: Custom Sort + +CO4. Set sort function `(a, b) => cmp(a.props.priority, b.props.priority)`. +Append A (priority=3), B (priority=1), C (priority=2). `children` yields B, C, +A. CO5. With active sort, change B's priority from 1 to 10. Next iteration of +`children` yields C, A, B (sort applied at read time, N18). CO6. Clear sort +function via `sort(undefined)`. `children` reverts to insertion order: A, B, C. + +### 5.3 Core: Sort Notification + +CO7. Install a sort function. Stream emits notification (N19, C20). CO8. Clear a +sort function. Stream emits notification (N19, C20). + +### 5.4 Extended: Sort Edge Cases + +CO9. Sort function that throws — behavior is implementation-defined but MUST NOT +corrupt the children list. CO10. Sort with equal comparisons — insertion order +is the tiebreaker (N16). + +--- + +## 5b. useNode Operation + +### 5b.1 Core: Node Resolution + +UN1. `useNode()` in the root component returns the root node. +`node === tree.root`. + +UN2. `useNode()` inside a child component returns the child node. `node.name` +matches the child's name and `node !== tree.root`. + +UN3. `useNode()` via `node.eval()` returns the eval target node. + +### 5b.2 Core: Middleware Interception + +UN4. Parent installs middleware on `useNode`. Child calls `useNode()`. Parent +middleware intercepts and may substitute a different node reference. + +--- + +## 6. Node Context API Middleware + +### 6.1 Core: Interception + +MW1. Parent installs middleware on `set`. Child calls `set("x", 1)`. Parent +middleware receives `["x", 1]` and `next`. Parent calls `next("x", 1)`. +`child.props["x"]` is `1`. + +MW2. Parent middleware transforms: receives `["x", 1]`, calls `next("x", 2)`. +`child.props["x"]` is `2`. + +MW3. Parent middleware rejects: receives `["x", 1]`, does NOT call `next`. +`child.props["x"]` is unchanged. + +MW4. Middleware on `update` receives the `[key, fn]` tuple. + +MW5. Middleware on `append` can intercept child creation. + +MW6. Middleware on `sort` can intercept sort installation. + +### 6.2 Core: Scope Isolation + +MW7. Middleware installed in node A's scope does NOT intercept operations in +node A's sibling B. + +MW8. Middleware installed in a parent's scope intercepts operations in all +descendants (scope inheritance). + +MW9. After `remove()` on a node, its middleware is inactive. Subsequent +operations in sibling nodes are not affected. + +--- + +## 7. Event Dispatch + +### 7.1 Core: Basic Dispatch + +ED1. Dispatch an event. Root middleware receives it. Returns +`{ ok: true, value: true }`. Result indicates handled. + +ED2. Dispatch an event with no middleware installed beyond the default handler. +Result is `{ ok: false }` — unhandled. + +ED3. Dispatch two events sequentially. Each is processed in order. The second +event's middleware sees state changes from the first. + +### 7.2 Core: Error Capture + +ED4. Middleware throws an exception. Result is `{ ok: false, error }`. The tree +is still alive. Subsequent dispatches work normally. + +ED5. Middleware throws in a child scope. The error is captured. The child node +is NOT destroyed (the dispatch loop catches the error, the scope survives). + +### 7.3 Core: Middleware Composition + +ED6. Root installs dispatch middleware. Child installs dispatch middleware. +Event flows through root middleware first (outermost), then child middleware. + +ED7. Root middleware can short-circuit by not calling `next`. Child middleware +never sees the event. + +ED8. Child middleware handles an event (returns handled result). Root +middleware's `next` receives the child's result. + +### 7.4 Core: Sequential Processing + +ED9. Dispatch event A, then event B while A is still processing. B is queued. B +is processed after A completes. + +ED10. Within one dispatch cycle, all property mutations are visible to +subsequent middleware in the same chain. + +--- + +## 8. Tree and Notification + +### 8.1 Core: Creation + +TN1. `createTree(root)` returns a Tree. `tree.root` is a Node. +`tree.root.parent` is `undefined`. + +TN2. The root component runs before `createTree` returns (T6). Properties set +during initialization are visible on `tree.root.props`. + +TN3. Middleware installed by the root component during initialization is active +for the first dispatched event. + +### 8.2 Core: Notification Stream + +TN4. Subscribe to tree stream. Call `set` on any node. Stream emits `void`. + +TN5. Subscribe to tree stream. Call `append`. Stream emits `void`. + +TN6. Subscribe to tree stream. Call `remove`. Stream emits `void`. + +TN7. Within one dispatch cycle, call `set` three times on different nodes. +Stream emits exactly ONE `void` (coalescing, T9). + +TN8. Within one dispatch cycle, call `set` and `append`. Stream emits exactly +ONE `void` (T10). + +TN9. Dispatch an event whose middleware makes no property or structural changes. +Stream MUST NOT emit (T8). + +### 8.3 Core: Initialization Notification + +TN10. Root component calls `set` during initialization (before any dispatch). +Stream emits a notification (T11). + +### 8.4 Core: Lifecycle + +TN11. Destroy the tree (exit its Effection scope). The output stream closes. + +TN12. Dispatch an event after the tree is destroyed. No error, no effect (T14). + +--- + +## 9. Component + +### 9.1 Core: Execution + +CP1. A component's generator runs when the node is created. + +CP2. A component that returns without looping — the node remains alive (P4). +Properties set during execution persist. Middleware installed during execution +remains active. + +CP3. A component in an infinite loop continues reacting until the node is +removed. + +CP4. A component can set properties, install middleware, and append children +before returning or entering a loop (P6). + +### 9.2 Core: Cancellation + +CP5. When a node is removed, the component's operation is cancelled. `finally` +blocks in the component run (Effection structured concurrency). + +--- + +## 10. Invariant Verification + +### 10.1 Core + +IV1. After any sequence of operations, the node tree and scope tree are +isomorphic (I1). Verify by walking both trees and comparing structure. + +IV2. After removing a subtree, no orphaned nodes exist (I2). Verify that removed +nodes do not appear in any `children` iteration. + +IV3. After removing a node, its middleware is inactive (I3). Verify by +dispatching an event and confirming the removed node's middleware is not called. + +IV4. Two concurrent dispatches do not interleave (I4). Verify by dispatching two +events that each set a property, and confirming the final state reflects +sequential execution. + +IV5. After a notification, `node.props` and `node.children` reflect the final +state of all mutations from the triggering cycle (I5). + +IV6. No invalid JsonValue can exist in any node's property bag at any time (I6). + +IV7. Node fields are read-only (I8). Direct mutation of `id`, `name`, `props`, +`children`, or `parent` is rejected or has no effect. + +--- + +## 11. Explicit Non-Tests + +The following scenarios are explicitly NOT tested because they correspond to +deferred extensions (§11 of the spec): + +NT1. JSX transform producing `append` calls. NT2. Key-based reconciliation of +children. NT3. Computed/derived properties. NT4. Tree query operations. NT5. +DOM-style event bubbling or capturing phases. NT6. Structured change records on +the output stream. NT7. Application-level demux middleware (tested by apps). +NT8. Event helper functions like `onkeydown` (app-level). NT9. Explicit index +ordering mode. diff --git a/packages/freedom/src/index.ts b/packages/freedom/src/index.ts new file mode 100644 index 0000000..945209d --- /dev/null +++ b/packages/freedom/src/index.ts @@ -0,0 +1 @@ +export * from "./lib/mod.ts"; diff --git a/packages/freedom/src/lib/dispatch.ts b/packages/freedom/src/lib/dispatch.ts new file mode 100644 index 0000000..37df53c --- /dev/null +++ b/packages/freedom/src/lib/dispatch.ts @@ -0,0 +1,23 @@ +import type { Api, Operation, Result } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./types.ts"; +import { TreeContext } from "./state.ts"; + +export interface Dispatch { + dispatch(event: unknown): Operation>; + getNodeById(id: string): Operation; +} + +export const DispatchApi: Api = createApi( + "freedom:dispatch", + { + *dispatch(_event: unknown): Operation> { + return { ok: false, error: new Error("unhandled") }; + }, + + *getNodeById(id: string): Operation { + let tree = yield* TreeContext.expect(); + return tree.nodes.get(id); + }, + }, +); diff --git a/packages/freedom/src/lib/focus.ts b/packages/freedom/src/lib/focus.ts new file mode 100644 index 0000000..c6e0fff --- /dev/null +++ b/packages/freedom/src/lib/focus.ts @@ -0,0 +1,138 @@ +import type { Api, Operation } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./types.ts"; +import { FreedomApi, set } from "./freedom.ts"; +import { NodeContext, type NodeImpl } from "./node.ts"; + +export interface Focus { + focusable(): Operation; + advance(): Operation; + retreat(): Operation; + focus(node: Node): Operation; + current(): Operation; +} + +function findRoot(node: Node): Node { + let n = node; + while (n.parent) { + n = n.parent; + } + return n; +} + +function focusChain(node: Node): Node[] { + let result: Node[] = []; + if ("focused" in node.props) { + result.push(node); + } + for (let child of node.children) { + result.push(...focusChain(child)); + } + return result; +} + +function* setFocused( + target: Node, + value: boolean, + self: NodeImpl, +): Operation { + if (target === self) { + yield* set("focused", value); + } else { + yield* target.eval(() => set("focused", value)); + } +} + +export const FocusApi: Api = createApi("freedom:focus", { + *focusable() { + let node = yield* NodeContext.expect(); + if (!("focused" in node.props)) { + yield* set("focused", false); + } + }, + + *advance() { + let self = yield* NodeContext.expect(); + let r = findRoot(self); + let nodes = focusChain(r); + if (nodes.length <= 1) return; + + let idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) return; + + let old = nodes[idx]; + let next = nodes[(idx + 1) % nodes.length]; + + yield* setFocused(old, false, self); + yield* setFocused(next, true, self); + }, + + *retreat() { + let self = yield* NodeContext.expect(); + let r = findRoot(self); + let nodes = focusChain(r); + if (nodes.length <= 1) return; + + let idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) return; + + let old = nodes[idx]; + let prev = nodes[(idx - 1 + nodes.length) % nodes.length]; + + yield* setFocused(old, false, self); + yield* setFocused(prev, true, self); + }, + + *focus(target: Node) { + if (!("focused" in target.props)) { + throw new Error("Cannot focus a non-focusable node"); + } + if (target.props.focused === true) return; + + let self = yield* NodeContext.expect(); + let r = findRoot(target); + let nodes = focusChain(r); + let old = nodes.find((n) => n.props.focused === true); + + if (old) { + yield* setFocused(old, false, self); + } + yield* setFocused(target, true, self); + }, + + *current() { + let node = yield* NodeContext.expect(); + let r = findRoot(node); + let nodes = focusChain(r); + let focused = nodes.find((n) => n.props.focused === true); + if (focused) { + return focused; + } else { + return r; + } + }, +}); + +export const focusable: typeof FocusApi.operations.focusable = + FocusApi.operations.focusable; +export const advance: typeof FocusApi.operations.advance = + FocusApi.operations.advance; +export const retreat: typeof FocusApi.operations.retreat = + FocusApi.operations.retreat; +export const focus: typeof FocusApi.operations.focus = + FocusApi.operations.focus; +export const current: typeof FocusApi.operations.current = + FocusApi.operations.current; + +export function* useFocus(): Operation { + yield* set("focused", true); + + yield* FreedomApi.around({ + *remove([node], next) { + if (node.props.focused === true) { + yield* FocusApi.operations.advance(); + } + yield* next(node); + }, + }); +} diff --git a/packages/freedom/src/lib/freedom.ts b/packages/freedom/src/lib/freedom.ts new file mode 100644 index 0000000..2cc9d0c --- /dev/null +++ b/packages/freedom/src/lib/freedom.ts @@ -0,0 +1,121 @@ +import { + type Api, + type Operation, + spawn, + suspend, + withResolvers, +} from "effection"; +import { createApi } from "effection/experimental"; +import { + type Component, + createNodeData, + type JsonValue, + type Node, +} from "./types.ts"; +import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; +import { TreeContext } from "./state.ts"; +import { validateJsonValue } from "./validate.ts"; + +const Halt = createNodeData<() => Operation>( + "freedom:halt", + function* () { + throw new Error("Cannot remove root node"); + }, +); + +export interface Freedom { + useNode(): Operation; + get(key: string): Operation; + set(key: string, value: JsonValue): Operation; + update( + key: string, + fn: (prev: JsonValue | undefined) => JsonValue, + ): Operation; + unset(key: string): Operation; + append(name: string, component: Component): Operation; + remove(node: Node): Operation; + sort(fn: ((a: Node, b: Node) => number) | undefined): Operation; +} + +export const FreedomApi: Api = createApi("freedom:node", { + useNode: () => NodeContext.expect(), + + *get(key: string): Operation { + let node = yield* NodeContext.expect(); + return node._props[key]; + }, + + *set(key: string, value: JsonValue) { + validateJsonValue(value); + let node = yield* NodeContext.expect(); + node._props[key] = value; + }, + + *update(key: string, fn: (prev: JsonValue | undefined) => JsonValue) { + let node = yield* NodeContext.expect(); + let prev = node._props[key]; + let next = fn(prev); + validateJsonValue(next); + node._props[key] = next; + }, + + *unset(key: string) { + let node = yield* NodeContext.expect(); + if (key in node._props) { + delete node._props[key]; + } + }, + + *append(name: string, component: Component): Operation { + let parent = yield* NodeContext.expect(); + let tree = yield* TreeContext.expect(); + let child = new NodeImpl(tree.nextId(), name, parent); + let ready = withResolvers(); + + let task = yield* spawn(function* () { + parent._children.add(child); + tree.nodes.set(child.id, child); + yield* NodeContext.set(child); + yield* spawnEvalLoop(child._channel); + ready.resolve(); + try { + yield* component(); + yield* suspend(); + } finally { + parent._children.delete(child); + tree.nodes.delete(child.id); + tree.markDirty(); + } + }); + child.data.set(Halt, task.halt); + child.remove = () => FreedomApi.operations.remove(child); + + yield* ready.operation; + return child; + }, + + *remove(node: Node) { + let halt = node.data.expect(Halt); + yield* halt(); + }, + + *sort(fn: ((a: Node, b: Node) => number) | undefined) { + let node = yield* NodeContext.expect(); + node._sortFn = fn; + }, +}); + +export const useNode: typeof FreedomApi.operations.useNode = + FreedomApi.operations.useNode; +export const get: typeof FreedomApi.operations.get = FreedomApi.operations.get; +export const set: typeof FreedomApi.operations.set = FreedomApi.operations.set; +export const update: typeof FreedomApi.operations.update = + FreedomApi.operations.update; +export const unset: typeof FreedomApi.operations.unset = + FreedomApi.operations.unset; +export const append: typeof FreedomApi.operations.append = + FreedomApi.operations.append; +export const remove: typeof FreedomApi.operations.remove = + FreedomApi.operations.remove; +export const sort: typeof FreedomApi.operations.sort = + FreedomApi.operations.sort; diff --git a/packages/freedom/src/lib/mod.ts b/packages/freedom/src/lib/mod.ts new file mode 100644 index 0000000..fe18a2d --- /dev/null +++ b/packages/freedom/src/lib/mod.ts @@ -0,0 +1,38 @@ +export type { + Component, + JsonValue, + Node, + NodeData, + NodeDataKey, + Tree, +} from "./types.ts"; + +export { createNodeData } from "./types.ts"; + +export { useTree } from "./tree.ts"; + +export { + append, + type Freedom, + FreedomApi, + get, + remove, + set, + sort, + unset, + update, + useNode, +} from "./freedom.ts"; + +export { type Dispatch, DispatchApi } from "./dispatch.ts"; + +export { + advance, + current, + type Focus, + focus, + focusable, + FocusApi, + retreat, + useFocus, +} from "./focus.ts"; diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts new file mode 100644 index 0000000..a6d1df6 --- /dev/null +++ b/packages/freedom/src/lib/node.ts @@ -0,0 +1,134 @@ +import { + type Channel, + type Context, + createChannel, + createContext, + Err, + Ok, + type Operation, + type Result, + spawn, + type Stream, + withResolvers, +} from "effection"; +import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; + +class NodeDataImpl implements NodeData { + _map: Map = new Map(); + + get(key: NodeDataKey): T | undefined { + return this._map.get(key.symbol) as T | undefined; + } + + set(key: NodeDataKey, value: T): void { + this._map.set(key.symbol, value); + } + + expect(key: NodeDataKey): T { + let val = this._map.get(key.symbol); + if (val !== undefined) { + return val as T; + } else if (key.defaultValue !== undefined) { + return key.defaultValue; + } else { + throw new Error(`NodeData '${key.symbol.description}' not found`); + } + } +} + +interface CallEval { + operation: () => Operation; + resolve: (result: Result) => void; +} + +function box(op: () => Operation): Operation> { + return { + *[Symbol.iterator]() { + try { + return Ok(yield* op()); + } catch (error) { + return Err(error as Error); + } + }, + }; +} + +export class NodeImpl implements Node { + _props: Record = {}; + _children: Set = new Set(); + _sortFn: ((a: Node, b: Node) => number) | undefined = undefined; + _channel: Channel = createChannel(); + data: NodeData = new NodeDataImpl(); + + constructor( + readonly id: string, + readonly name: string, + readonly _parent: NodeImpl | undefined, + ) {} + + get props(): Record { + return Object.freeze({ ...this._props }); + } + + get children(): Iterable { + if (this._sortFn) { + let fn = this._sortFn; + let indexed = [...this._children].map((c, i) => [c, i] as const); + indexed.sort(([a, ai], [b, bi]) => { + let result = fn(a, b); + if (result !== 0) { + return result; + } else { + return ai - bi; + } + }); + return indexed.map(([c]) => c); + } else { + return this._children; + } + } + + get parent(): Node | undefined { + return this._parent; + } + + *eval(op: () => Operation): Operation> { + let resolver = withResolvers>(); + yield* this._channel.send({ + resolve: resolver.resolve as (result: Result) => void, + operation: op as () => Operation, + }); + return yield* resolver.operation; + } + + remove(): Operation { + throw new Error("Cannot remove root node"); + } +} + +export function* spawnEvalLoop( + channel: Channel, +): Operation { + let ready = withResolvers(); + + yield* spawn(function* () { + let sub = yield* channel as Stream; + ready.resolve(); + + while (true) { + let next = yield* sub.next(); + if (next.done) { + break; + } + let call = next.value; + let result = yield* box(call.operation); + call.resolve(result); + } + }); + + yield* ready.operation; +} + +export const NodeContext: Context = createContext( + "freedom:current-node", +); diff --git a/packages/freedom/src/lib/state.ts b/packages/freedom/src/lib/state.ts new file mode 100644 index 0000000..e1a1540 --- /dev/null +++ b/packages/freedom/src/lib/state.ts @@ -0,0 +1,13 @@ +import { createContext, type Signal } from "effection"; +import type { NodeImpl } from "./node.ts"; + +export interface TreeState { + dirty: boolean; + output: Signal; + events: Signal; + nodes: Map; + nextId(): string; + markDirty(): void; +} + +export const TreeContext = createContext("freedom:tree"); diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts new file mode 100644 index 0000000..35755ca --- /dev/null +++ b/packages/freedom/src/lib/tree.ts @@ -0,0 +1,112 @@ +import { + createSignal, + type Operation, + resource, + spawn, + suspend, + withResolvers, +} from "effection"; +import type { Component, Tree } from "./types.ts"; +import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; +import { TreeContext, type TreeState } from "./state.ts"; +import { DispatchApi } from "./dispatch.ts"; +import { FreedomApi } from "./freedom.ts"; + +export function useTree(root: Component): Operation { + return resource(function* (provide) { + let output = createSignal(); + let events = createSignal(); + + let counter = 0; + let state: TreeState = { + dirty: false, + output, + events, + nodes: new Map(), + nextId() { + return `node-${++counter}`; + }, + markDirty() { + state.dirty = true; + }, + }; + + yield* TreeContext.set(state); + + let rootNode = new NodeImpl(state.nextId(), "", undefined); + rootNode.remove = () => FreedomApi.operations.remove(rootNode); + state.nodes.set(rootNode.id, rootNode); + + let ready = withResolvers(); + + // Spawn root node scope + yield* spawn(function* () { + yield* NodeContext.set(rootNode); + + // Mark dirty after every mutation + yield* FreedomApi.around({ + *set(args, next) { + yield* next(...args); + state.markDirty(); + }, + *update(args, next) { + yield* next(...args); + state.markDirty(); + }, + *unset(args, next) { + yield* next(...args); + state.markDirty(); + }, + *append(args, next) { + let node = yield* next(...args); + state.markDirty(); + return node; + }, + *remove(args, next) { + yield* next(...args); + state.markDirty(); + }, + *sort(args, next) { + yield* next(...args); + state.markDirty(); + }, + }); + + yield* spawnEvalLoop(rootNode._channel); + + // Subscribe to events, then spawn the event loop + let sub = yield* events; + yield* spawn(function* () { + while (true) { + let next = yield* sub.next(); + if (next.done) { + break; + } + let event = next.value; + state.dirty = false; + yield* rootNode.eval(() => DispatchApi.operations.dispatch(event)); + if (state.dirty) { + output.send(); + } + } + }); + + ready.resolve(); + + yield* root(); + yield* suspend(); + }); + + yield* ready.operation; + + let tree: Tree = { + dispatch(event: unknown) { + events.send(event); + }, + root: rootNode, + [Symbol.iterator]: output[Symbol.iterator], + }; + + yield* provide(tree); + }); +} diff --git a/packages/freedom/src/lib/types.ts b/packages/freedom/src/lib/types.ts new file mode 100644 index 0000000..a3df876 --- /dev/null +++ b/packages/freedom/src/lib/types.ts @@ -0,0 +1,45 @@ +import type { Operation, Result, Stream } from "effection"; + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type Component = () => Operation; + +export interface NodeDataKey { + readonly symbol: symbol; + readonly defaultValue?: T; +} + +export function createNodeData( + name: string, + defaultValue?: T, +): NodeDataKey { + return { symbol: Symbol(name), defaultValue }; +} + +export interface NodeData { + get(key: NodeDataKey): T | undefined; + set(key: NodeDataKey, value: T): void; + expect(key: NodeDataKey): T; +} + +export interface Node { + readonly id: string; + readonly name: string; + readonly props: Record; + readonly children: Iterable; + readonly parent: Node | undefined; + readonly data: NodeData; + eval(op: () => Operation): Operation>; + remove(): Operation; +} + +export interface Tree extends Stream { + dispatch(event: unknown): void; + root: Node; +} diff --git a/packages/freedom/src/lib/validate.ts b/packages/freedom/src/lib/validate.ts new file mode 100644 index 0000000..e37f9f9 --- /dev/null +++ b/packages/freedom/src/lib/validate.ts @@ -0,0 +1,55 @@ +import type { JsonValue } from "./types.ts"; + +export function validateJsonValue(value: unknown): asserts value is JsonValue { + if (value === undefined) { + throw new Error("undefined is not a valid JsonValue"); + } + if (typeof value === "number") { + if (Number.isNaN(value)) { + throw new Error("NaN is not a valid JsonValue"); + } + if (!Number.isFinite(value)) { + throw new Error(`${value} is not a valid JsonValue`); + } + return; + } + if ( + typeof value === "string" || typeof value === "boolean" || value === null + ) { + return; + } + if (typeof value === "function") { + throw new Error("functions are not valid JsonValues"); + } + if (typeof value === "symbol") { + throw new Error("symbols are not valid JsonValues"); + } + if (typeof value === "bigint") { + throw new Error("bigints are not valid JsonValues"); + } + if (value instanceof Date) { + throw new Error("Date instances are not valid JsonValues"); + } + if (value instanceof Map) { + throw new Error("Map instances are not valid JsonValues"); + } + if (value instanceof Set) { + throw new Error("Set instances are not valid JsonValues"); + } + if (value instanceof RegExp) { + throw new Error("RegExp instances are not valid JsonValues"); + } + if (Array.isArray(value)) { + for (let item of value) { + validateJsonValue(item); + } + return; + } + if (typeof value === "object" && value !== null) { + for (let key of Object.keys(value)) { + validateJsonValue((value as Record)[key]); + } + return; + } + throw new Error(`${String(value)} is not a valid JsonValue`); +} diff --git a/packages/freedom/test/focus.test.ts b/packages/freedom/test/focus.test.ts new file mode 100644 index 0000000..ae34c05 --- /dev/null +++ b/packages/freedom/test/focus.test.ts @@ -0,0 +1,432 @@ +import { describe, it } from "../test/suite.ts"; +import { expect } from "../test/helpers.ts"; +import { run } from "effection"; +import { + advance, + append, + current, + focus, + focusable, + retreat, + set, + useFocus, + useTree, +} from "../mod.ts"; + +describe("Focus installation", () => { + it("FI1-FI3: useFocus sets root as focused", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + }); + + yield* expect(tree.root).toEval(function* () { + let node = yield* current(); + expect(node).toBe(tree.root); + expect(tree.root.props.focused).toBe(true); + }); + }); + }); +}); + +describe("focusable()", () => { + it("FF1-FF2: focusable sets focused:false on the node", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("child", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + expect(children[0].props.focused).toBe(false); + }); + }); + }); + + it("FF3: focusable on already-focusable node is no-op", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("child", function* () { + yield* focusable(); + yield* focusable(); // second call + }); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + expect(children[0].props.focused).toBe(false); + }); + }); + }); + + it("FF4: node without focusable is not in focus chain", async () => { + await run(function* () { + yield* useTree(function* () { + yield* useFocus(); + yield* append("nonfocusable", function* () { + yield* set("label", "skip me"); + }); + yield* append("focusable", function* () { + yield* focusable(); + }); + }); + + // advance from root should skip nonfocusable child + // (eval sequences after component has run) + }); + }); +}); + +describe("Focus chain", () => { + it("FC1: depth-first order with flat children", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + yield* append("B", function* () { + yield* focusable(); + }); + yield* append("C", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let names: string[] = []; + for (let i = 0; i < 4; i++) { + let node = yield* current(); + names.push(node.name); + yield* advance(); + } + expect(names).toEqual(["", "A", "B", "C"]); + }); + }); + }); + + it("FC2: depth-first order with nested children", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + yield* append("A1", function* () { + yield* focusable(); + }); + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let names: string[] = []; + for (let i = 0; i < 4; i++) { + let node = yield* current(); + names.push(node.name); + yield* advance(); + } + expect(names).toEqual(["", "A", "A1", "B"]); + }); + }); + }); + + it("FC3: non-focusable nodes are skipped", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + // not focusable + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let names: string[] = []; + for (let i = 0; i < 2; i++) { + let node = yield* current(); + names.push(node.name); + yield* advance(); + } + expect(names).toEqual(["", "B"]); + }); + }); + }); +}); + +describe("advance()", () => { + it("FA1-FA3: moves focus forward", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + expect(tree.root.props.focused).toBe(true); + yield* advance(); + expect(tree.root.props.focused).toBe(false); + let children = [...tree.root.children]; + expect(children[0].props.focused).toBe(true); + }); + }); + }); + + it("FA4: wraps from last to first", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + yield* advance(); + yield* advance(); + let node = yield* current(); + expect(node).toBe(tree.root); + expect(tree.root.props.focused).toBe(true); + }); + }); + }); + + it("FA5: single focusable node is a no-op", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + }); + + yield* expect(tree.root).toEval(function* () { + yield* advance(); + let node = yield* current(); + expect(node).toBe(tree.root); + expect(tree.root.props.focused).toBe(true); + }); + }); + }); +}); + +describe("retreat()", () => { + it("FR1: moves focus backward", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + yield* advance(); // root -> A + yield* advance(); // A -> B + let node = yield* current(); + expect(node.name).toEqual("B"); + + yield* retreat(); // B -> A + node = yield* current(); + expect(node.name).toEqual("A"); + }); + }); + }); + + it("FR2: wraps from first to last", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + yield* retreat(); + let node = yield* current(); + expect(node.name).toEqual("B"); + }); + }); + }); + + it("FR3: single node is a no-op", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + }); + + yield* expect(tree.root).toEval(function* () { + yield* retreat(); + let node = yield* current(); + expect(node).toBe(tree.root); + }); + }); + }); +}); + +describe("focus(node)", () => { + it("FE1: explicit focus changes focused node", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + let b = children[1]; + yield* focus(b); + let node = yield* current(); + expect(node).toBe(b); + expect(tree.root.props.focused).toBe(false); + expect(b.props.focused).toBe(true); + }); + }); + }); + + it("FE2: focus on non-focusable node raises error", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("nonfocusable", function* () {}); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + try { + yield* focus(children[0]); + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toContain("non-focusable"); + } + }); + }); + }); + + it("FE3: focus on already-focused node is no-op", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + }); + + yield* expect(tree.root).toEval(function* () { + yield* focus(tree.root); + let node = yield* current(); + expect(node).toBe(tree.root); + expect(tree.root.props.focused).toBe(true); + }); + }); + }); +}); + +describe("current()", () => { + it("CU1-CU3: returns the focused node", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let node = yield* current(); + expect(node).toBe(tree.root); + + yield* advance(); + node = yield* current(); + expect(node.name).toEqual("A"); + }); + }); + }); +}); + +describe("Focused node removal", () => { + it("FR1: removing focused node advances focus", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + let a = children[0]; + yield* focus(a); + expect(a.props.focused).toBe(true); + yield* a.remove(); + + let node = yield* current(); + expect(node.name).toEqual("B"); + }); + }); + }); + + it("FR4: removing non-focused node does not move focus", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + yield* append("B", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + let b = children[1]; + yield* b.remove(); + let node = yield* current(); + expect(node).toBe(tree.root); + }); + }); + }); + + it("FR5: removing only non-root focusable returns focus to root", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* useFocus(); + yield* append("A", function* () { + yield* focusable(); + }); + }); + + yield* expect(tree.root).toEval(function* () { + let children = [...tree.root.children]; + let a = children[0]; + yield* focus(a); + expect(a.props.focused).toBe(true); + yield* a.remove(); + + let node = yield* current(); + expect(node).toBe(tree.root); + expect(tree.root.props.focused).toBe(true); + }); + }); + }); +}); diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts new file mode 100644 index 0000000..41a9168 --- /dev/null +++ b/packages/freedom/test/freedom.test.ts @@ -0,0 +1,659 @@ +import { describe, expect, it } from "../test/suite.ts"; +import { run, sleep } from "effection"; +import { + append, + DispatchApi, + FreedomApi, + get, + set, + sort, + unset, + update, + useNode, + useTree, +} from "../mod.ts"; + +describe("JsonValue validation", () => { + it("JV1-JV12: accepts valid JsonValues", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + let result = yield* tree.root.eval(function* () { + yield* set("str", "hello"); + yield* set("num", 42); + yield* set("zero", 0); + yield* set("neg", -1.5); + yield* set("bool", true); + yield* set("boolFalse", false); + yield* set("nil", null); + yield* set("arr", [1, "a", true, null]); + yield* set("obj", { a: 1, b: "c" }); + yield* set("nested", { nested: { deep: [1, 2] } }); + yield* set("emptyArr", []); + yield* set("emptyObj", {}); + }); + expect(result.ok).toBe(true); + expect(tree.root.props["str"]).toEqual("hello"); + expect(tree.root.props["num"]).toEqual(42); + expect(tree.root.props["nil"]).toEqual(null); + expect(tree.root.props["nested"]).toEqual({ nested: { deep: [1, 2] } }); + }); + }); + + it("JV13: rejects undefined", async () => { + await run(function* () { + yield* useTree(function* () { + try { + // deno-lint-ignore no-explicit-any + yield* set("k", undefined as any); + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toContain("undefined"); + } + }); + }); + }); + + it("JV14-JV16: rejects NaN and Infinity", async () => { + await run(function* () { + yield* useTree(function* () { + for (let val of [NaN, Infinity, -Infinity]) { + try { + yield* set("k", val); + expect(true).toBe(false); + } catch (_e) { + // expected + } + } + }); + }); + }); + + it("JV17-JV20: rejects non-JSON types", async () => { + await run(function* () { + yield* useTree(function* () { + for (let val of [() => {}, Symbol(), new Date(), new Map()]) { + try { + // deno-lint-ignore no-explicit-any + yield* set("k", val as any); + expect(true).toBe(false); + } catch (_e) { + // expected + } + } + }); + }); + }); + + it("JV21-JV23: validates update return values", async () => { + await run(function* () { + yield* useTree(function* () { + yield* set("n", 1); + yield* update("n", () => 42); + + try { + yield* update("n", () => undefined as unknown as number); + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toContain("undefined"); + } + }); + }); + }); +}); + +describe("Node lifecycle", () => { + it("NL1-NL6: creates child nodes via append", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* append("child", function* () { + yield* set("init", true); + }); + }); + yield* sleep(0); + + let children = [...tree.root.children]; + expect(children.length).toEqual(1); + expect(children[0].name).toEqual("child"); + expect(children[0].parent).toBe(tree.root); + expect(children[0].props["init"]).toEqual(true); + }); + }); + + it("NL7-NL11: assigns unique ids", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* append("a", function* () {}); + yield* append("b", function* () {}); + }); + yield* sleep(0); + + expect(tree.root.id).toBeTruthy(); + let children = [...tree.root.children]; + expect(children[0].id).not.toEqual(children[1].id); + expect(tree.root.id).not.toEqual(children[0].id); + }); + }); + + it("NL12: remove() removes node from parent", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + let result = yield* tree.root.eval(function* () { + let child = yield* append("child", function* () { + yield* set("name", "child"); + }); + yield* child.remove(); + }); + expect(result.ok).toBe(true); + expect([...tree.root.children].length).toEqual(0); + }); + }); + + it("N12: remove() on root raises error", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + let result = yield* tree.root.eval(function* () { + yield* tree.root.remove(); + }); + expect(result.ok).toBe(false); + }); + }); + + it("NL18: init-only component keeps node alive", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* set("alive", true); + }); + expect(tree.root.props["alive"]).toEqual(true); + }); + }); +}); + +describe("Property bag", () => { + it("PB1-PB5: set operations", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* set("a", 1); + yield* set("a", 2); + yield* set("b", 2); + yield* set("ns", { x: 1, y: 2 }); + }); + expect(tree.root.props["a"]).toEqual(2); + expect(tree.root.props["b"]).toEqual(2); + expect(tree.root.props["ns"]).toEqual({ x: 1, y: 2 }); + }); + }); + + it("PB6-PB8: update operations", async () => { + await run(function* () { + yield* useTree(function* () { + yield* set("n", 1); + yield* update("n", (v) => (v as number) + 1); + yield* update("missing", (v) => v ?? 0); + }); + }); + }); + + it("PB9: unset removes key", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* set("a", 1); + yield* unset("a"); + }); + expect("a" in tree.root.props).toBe(false); + }); + }); + + it("PB10: unset nonexistent is no-op", async () => { + await run(function* () { + yield* useTree(function* () { + yield* unset("nonexistent"); + }); + }); + }); + + it("PB11: props is read-only", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* set("x", 1); + }); + expect(() => { + // deno-lint-ignore no-explicit-any + (tree.root.props as any)["x"] = 2; + }).toThrow(); + }); + }); +}); + +describe("Child ordering", () => { + it("CO1-CO3: insertion order", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* append("A", function* () {}); + yield* append("B", function* () {}); + yield* append("C", function* () {}); + }); + yield* sleep(0); + + let names = [...tree.root.children].map((c) => c.name); + expect(names).toEqual(["A", "B", "C"]); + }); + }); + + it("CO4-CO6: custom sort", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* sort((a, b) => { + let ap = a.props["priority"] as number; + let bp = b.props["priority"] as number; + return ap - bp; + }); + yield* append("A", function* () { + yield* set("priority", 3); + }); + yield* append("B", function* () { + yield* set("priority", 1); + }); + yield* append("C", function* () { + yield* set("priority", 2); + }); + }); + yield* sleep(0); + + let names = [...tree.root.children].map((c) => c.name); + expect(names).toEqual(["B", "C", "A"]); + }); + }); +}); + +describe("Node.eval", () => { + it("runs operations in the node's scope", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* set("before", true); + }); + + let result = yield* tree.root.eval(function* () { + yield* set("after", true); + return 42; + }); + + expect(result).toEqual({ ok: true, value: 42 }); + expect(tree.root.props["after"]).toEqual(true); + }); + }); + + it("captures errors as Result", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + + let result = yield* tree.root.eval(function* () { + throw new Error("boom"); + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toEqual("boom"); + } + }); + }); +}); + +describe("Tree and notification", () => { + it("TN1: useTree returns tree with root", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + expect(tree.root).toBeTruthy(); + expect(tree.root.parent).toBeUndefined(); + }); + }); + + it("TN2: root props visible via eval", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* set("ready", true); + }); + expect(tree.root.props["ready"]).toEqual(true); + }); + }); +}); + +describe("Event dispatch", () => { + it("ED1: middleware handles event via root.eval", async () => { + await run(function* () { + let handled = false; + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* DispatchApi.around({ + *dispatch([event], next) { + if (event === "ping") { + handled = true; + return { ok: true as const, value: true as const }; + } + return yield* next(event); + }, + }); + }); + tree.dispatch("ping"); + yield* sleep(0); + expect(handled).toBe(true); + }); + }); + + it("ED2: unhandled event", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + tree.dispatch("unknown"); + yield* sleep(0); + expect(tree.root).toBeTruthy(); + }); + }); + + it("ED3: sequential event processing", async () => { + await run(function* () { + let order: string[] = []; + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* DispatchApi.around({ + *dispatch([event], _next) { + order.push(event as string); + yield* set("last", event as string); + return { ok: true as const, value: true as const }; + }, + }); + }); + tree.dispatch("first"); + tree.dispatch("second"); + yield* sleep(0); + yield* sleep(0); + expect(order).toEqual(["first", "second"]); + expect(tree.root.props["last"]).toEqual("second"); + }); + }); + + it("ED4: middleware error captured, tree survives", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* DispatchApi.around({ + *dispatch([_event], _next) { + throw new Error("boom"); + }, + }); + }); + tree.dispatch("test"); + yield* sleep(0); + expect(tree.root).toBeTruthy(); + + // Subsequent dispatch works + tree.dispatch("test2"); + yield* sleep(0); + }); + }); + + it("ED-getNodeById: dispatch middleware resolves target node", async () => { + await run(function* () { + let resolved = false; + let tree = yield* useTree(function* () {}); + + // Install middleware + yield* tree.root.eval(function* () { + yield* DispatchApi.around({ + *dispatch([event], next) { + let ev = event as { type: string; targetId: string }; + if (ev.type === "focus") { + let node = yield* DispatchApi.operations.getNodeById(ev.targetId); + if (node) { + resolved = true; + expect(node.name).toEqual("target"); + expect(node.props["found"]).toEqual(true); + } + return { ok: true as const, value: true as const }; + } + return yield* next(event); + }, + }); + }); + + // Append child — spawn is lazy, so eval again to get the id + // after the child has registered + let id = yield* tree.root.eval(function* () { + let child = yield* append("target", function* () { + yield* set("found", true); + }); + return child.id; + }); + + if (id.ok) { + // By now the child spawn has run (eval sequentializes) + tree.dispatch({ type: "focus", targetId: id.value }); + yield* sleep(0); + expect(resolved).toBe(true); + } else { + expect(true).toBe(false); + } + }); + }); +}); + +describe("Notification coalescing", () => { + it("TN7: multiple sets in one dispatch = one notification", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* DispatchApi.around({ + *dispatch([_event], _next) { + yield* set("a", 1); + yield* set("b", 2); + yield* set("c", 3); + return { ok: true as const, value: true as const }; + }, + }); + }); + + let sub = yield* tree; + tree.dispatch("multi-set"); + let next = yield* sub.next(); + expect(next.done).toBe(false); + }); + }); + + it("TN9: no-change dispatch does not notify", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* DispatchApi.around({ + *dispatch([_event], _next) { + return { ok: true as const, value: true as const }; + }, + }); + }); + + yield* tree; + tree.dispatch("no-op"); + yield* sleep(0); + // No notification emitted — dirty was false + }); + }); +}); + +describe("get operation", () => { + it("GA1: get returns stored value", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + yield* set("k", 42); + let val = yield* get("k"); + expect(val).toEqual(42); + }); + }); + }); + + it("GA2: get returns undefined for missing key", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + yield* tree.root.eval(function* () { + let val = yield* get("missing"); + expect(val).toBeUndefined(); + }); + }); + }); + + it("GA3: get middleware can intercept reads", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* set("k", 1); + yield* FreedomApi.around({ + *get([key], next) { + let val = yield* next(key); + if (key === "k") { + return (val as number) * 10; + } + return val; + }, + }); + }); + let result = yield* tree.root.eval(function* () { + return yield* get("k"); + }); + expect(result).toEqual({ ok: true, value: 10 }); + }); + }); +}); + +describe("remove operation", () => { + it("RA1: remove destroys child node", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + let result = yield* tree.root.eval(function* () { + let child = yield* append("child", function* () {}); + yield* child.remove(); + }); + expect(result.ok).toBe(true); + expect([...tree.root.children].length).toEqual(0); + }); + }); + + it("RA2: remove on root raises error", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + let result = yield* tree.root.eval(function* () { + yield* tree.root.remove(); + }); + expect(result.ok).toBe(false); + }); + }); + + it("RA3: remove middleware can intercept removal", async () => { + await run(function* () { + let intercepted = false; + let tree = yield* useTree(function* () { + yield* FreedomApi.around({ + *remove([node], next) { + intercepted = true; + yield* next(node); + }, + }); + }); + yield* tree.root.eval(function* () { + let child = yield* append("child", function* () {}); + yield* child.remove(); + }); + expect(intercepted).toBe(true); + }); + }); + + it("RA4: remove middleware runs before teardown", async () => { + await run(function* () { + let nameBeforeTeardown = ""; + let tree = yield* useTree(function* () { + yield* FreedomApi.around({ + *remove([node], next) { + nameBeforeTeardown = node.name; + let found = [...tree.root.children].find( + (c) => c.name === node.name, + ); + expect(found).toBeTruthy(); + yield* next(node); + }, + }); + }); + yield* tree.root.eval(function* () { + let child = yield* append("target", function* () {}); + yield* child.remove(); + }); + expect(nameBeforeTeardown).toEqual("target"); + }); + }); +}); + +describe("useNode operation", () => { + it("UN1: returns root node in root component", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + let node = yield* useNode(); + expect(node).toBe(tree.root); + }); + }); + }); + + it("UN2: returns child node in child component", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* append("child", function* () { + let node = yield* useNode(); + expect(node).not.toBe(tree.root); + expect(node.name).toEqual("child"); + }); + }); + yield* sleep(0); + }); + }); + + it("UN3: returns eval target node via node.eval", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + let child = yield* tree.root.eval(function* () { + return yield* append("target", function* () {}); + }); + if (!child.ok) throw child.error; + + let result = yield* child.value.eval(function* () { + return yield* useNode(); + }); + if (!result.ok) throw result.error; + + expect(result.value).toBe(child.value); + }); + }); + + it("UN4: middleware can intercept useNode", async () => { + await run(function* () { + let tree = yield* useTree(function* () { + yield* FreedomApi.around({ + *useNode(_, next) { + let node = yield* next(); + return node; + }, + }); + }); + + let intercepted = false; + yield* tree.root.eval(function* () { + let child = yield* append("child", function* () {}); + yield* FreedomApi.around({ + *useNode(_, next) { + intercepted = true; + return yield* next(); + }, + }); + yield* child.eval(function* () { + yield* useNode(); + }); + }); + expect(intercepted).toBe(true); + }); + }); +}); diff --git a/packages/freedom/test/helpers.ts b/packages/freedom/test/helpers.ts new file mode 100644 index 0000000..76083d0 --- /dev/null +++ b/packages/freedom/test/helpers.ts @@ -0,0 +1,35 @@ +import { expect as stdExpect } from "@std/expect"; +import type { Expected } from "@std/expect"; +import { type Operation, sleep } from "effection"; +import type { Node } from "../mod.ts"; + +interface NodeExpected extends Expected { + toEval(fn: () => Operation): Operation; +} + +export function expect(value: Node): NodeExpected; +export function expect(value: T): Expected; +export function expect(value: unknown): NodeExpected | Expected { + let base = stdExpect(value); + if (value && typeof value === "object" && "eval" in value) { + let node = value as Node; + return new Proxy(base as unknown as NodeExpected, { + get(target, prop, receiver) { + if (prop === "toEval") { + return (fn: () => Operation): Operation => ({ + *[Symbol.iterator]() { + // let pending child component tasks settle + yield* sleep(0); + let r = yield* node.eval(fn); + if (!r.ok) { + throw r.error; + } + }, + }); + } + return Reflect.get(target, prop, receiver); + }, + }); + } + return base; +} diff --git a/packages/freedom/test/suite.ts b/packages/freedom/test/suite.ts new file mode 100644 index 0000000..c2ba8e6 --- /dev/null +++ b/packages/freedom/test/suite.ts @@ -0,0 +1,2 @@ +export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +export { expect } from "@std/expect"; From 6c8e9bc2842ee264357ce37486aa37e2eb460867 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 21:41:55 -0500 Subject: [PATCH 02/47] =?UTF-8?q?=F0=9F=94=A7=20add=20Node=20manifest=20an?= =?UTF-8?q?d=20tsconfig=20for=20@bomb.sh/freedom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/package.json | 49 ++++++++++++++++++++++++++++++++++ packages/freedom/tsconfig.json | 5 ++++ 2 files changed, 54 insertions(+) create mode 100644 packages/freedom/package.json create mode 100644 packages/freedom/tsconfig.json diff --git a/packages/freedom/package.json b/packages/freedom/package.json new file mode 100644 index 0000000..19d39b3 --- /dev/null +++ b/packages/freedom/package.json @@ -0,0 +1,49 @@ +{ + "name": "@bomb.sh/freedom", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "ISC", + "author": { + "name": "Bombshell", + "email": "oss@bomb.sh", + "url": "https://bomb.sh" + }, + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "dev": "bsh dev", + "build": "bsh build", + "format": "bsh format", + "lint": "bsh lint", + "test": "bsh test" + }, + "dependencies": { + "effection": "4.1.0-alpha.9" + }, + "devDependencies": { + "@bomb.sh/tools": "latest", + "@types/node": "^26.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/playground.git", + "directory": "packages/freedom" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + }, + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + } + } +} diff --git a/packages/freedom/tsconfig.json b/packages/freedom/tsconfig.json new file mode 100644 index 0000000..b9fbbb0 --- /dev/null +++ b/packages/freedom/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "@bomb.sh/tools/tsconfig.json" + ] +} From 1ffe7b75587ceaf06e2bb4d780d7aaffc4e7195f Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 21:50:51 -0500 Subject: [PATCH 03/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20port=20freedom=20tes?= =?UTF-8?q?t=20harness=20to=20vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/test/focus.test.ts | 2 +- packages/freedom/test/freedom.test.ts | 2 +- packages/freedom/test/helpers.ts | 16 ++++++++-------- packages/freedom/test/suite.ts | 3 +-- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/freedom/test/focus.test.ts b/packages/freedom/test/focus.test.ts index ae34c05..cb80c57 100644 --- a/packages/freedom/test/focus.test.ts +++ b/packages/freedom/test/focus.test.ts @@ -11,7 +11,7 @@ import { set, useFocus, useTree, -} from "../mod.ts"; +} from "../src/index.ts"; describe("Focus installation", () => { it("FI1-FI3: useFocus sets root as focused", async () => { diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 41a9168..1148ad2 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -11,7 +11,7 @@ import { update, useNode, useTree, -} from "../mod.ts"; +} from "../src/index.ts"; describe("JsonValue validation", () => { it("JV1-JV12: accepts valid JsonValues", async () => { diff --git a/packages/freedom/test/helpers.ts b/packages/freedom/test/helpers.ts index 76083d0..f01479f 100644 --- a/packages/freedom/test/helpers.ts +++ b/packages/freedom/test/helpers.ts @@ -1,16 +1,16 @@ -import { expect as stdExpect } from "@std/expect"; -import type { Expected } from "@std/expect"; +import { expect as viExpect } from "vitest"; import { type Operation, sleep } from "effection"; -import type { Node } from "../mod.ts"; +import type { Node } from "../src/index.ts"; -interface NodeExpected extends Expected { +type Assertion = ReturnType; +type NodeExpected = Assertion & { toEval(fn: () => Operation): Operation; -} +}; export function expect(value: Node): NodeExpected; -export function expect(value: T): Expected; -export function expect(value: unknown): NodeExpected | Expected { - let base = stdExpect(value); +export function expect(value: T): Assertion; +export function expect(value: unknown): NodeExpected | Assertion { + let base = viExpect(value); if (value && typeof value === "object" && "eval" in value) { let node = value as Node; return new Proxy(base as unknown as NodeExpected, { diff --git a/packages/freedom/test/suite.ts b/packages/freedom/test/suite.ts index c2ba8e6..4be959e 100644 --- a/packages/freedom/test/suite.ts +++ b/packages/freedom/test/suite.ts @@ -1,2 +1 @@ -export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; -export { expect } from "@std/expect"; +export { afterEach, beforeEach, describe, it, expect } from "vitest"; From ad68ce0852cf5af3d91cf3cc324fd082b7ca7e82 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 23:47:38 -0500 Subject: [PATCH 04/47] =?UTF-8?q?=F0=9F=8E=A8=20adopt=20house=20const=20st?= =?UTF-8?q?yle=20and=20disable=20require-yield=20in=20freedom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/src/lib/dispatch.ts | 3 +- packages/freedom/src/lib/focus.ts | 46 ++++++++++++++-------------- packages/freedom/src/lib/freedom.ts | 27 ++++++++-------- packages/freedom/src/lib/node.ts | 20 ++++++------ packages/freedom/src/lib/tree.ts | 20 ++++++------ packages/freedom/src/lib/validate.ts | 4 +-- 6 files changed, 61 insertions(+), 59 deletions(-) diff --git a/packages/freedom/src/lib/dispatch.ts b/packages/freedom/src/lib/dispatch.ts index 37df53c..d14286d 100644 --- a/packages/freedom/src/lib/dispatch.ts +++ b/packages/freedom/src/lib/dispatch.ts @@ -1,3 +1,4 @@ +// oxlint-disable require-yield import type { Api, Operation, Result } from "effection"; import { createApi } from "effection/experimental"; import type { Node } from "./types.ts"; @@ -16,7 +17,7 @@ export const DispatchApi: Api = createApi( }, *getNodeById(id: string): Operation { - let tree = yield* TreeContext.expect(); + const tree = yield* TreeContext.expect(); return tree.nodes.get(id); }, }, diff --git a/packages/freedom/src/lib/focus.ts b/packages/freedom/src/lib/focus.ts index c6e0fff..3bd246a 100644 --- a/packages/freedom/src/lib/focus.ts +++ b/packages/freedom/src/lib/focus.ts @@ -21,11 +21,11 @@ function findRoot(node: Node): Node { } function focusChain(node: Node): Node[] { - let result: Node[] = []; + const result: Node[] = []; if ("focused" in node.props) { result.push(node); } - for (let child of node.children) { + for (const child of node.children) { result.push(...focusChain(child)); } return result; @@ -45,39 +45,39 @@ function* setFocused( export const FocusApi: Api = createApi("freedom:focus", { *focusable() { - let node = yield* NodeContext.expect(); + const node = yield* NodeContext.expect(); if (!("focused" in node.props)) { yield* set("focused", false); } }, *advance() { - let self = yield* NodeContext.expect(); - let r = findRoot(self); - let nodes = focusChain(r); + const self = yield* NodeContext.expect(); + const r = findRoot(self); + const nodes = focusChain(r); if (nodes.length <= 1) return; - let idx = nodes.findIndex((n) => n.props.focused === true); + const idx = nodes.findIndex((n) => n.props.focused === true); if (idx === -1) return; - let old = nodes[idx]; - let next = nodes[(idx + 1) % nodes.length]; + const old = nodes[idx]; + const next = nodes[(idx + 1) % nodes.length]; yield* setFocused(old, false, self); yield* setFocused(next, true, self); }, *retreat() { - let self = yield* NodeContext.expect(); - let r = findRoot(self); - let nodes = focusChain(r); + const self = yield* NodeContext.expect(); + const r = findRoot(self); + const nodes = focusChain(r); if (nodes.length <= 1) return; - let idx = nodes.findIndex((n) => n.props.focused === true); + const idx = nodes.findIndex((n) => n.props.focused === true); if (idx === -1) return; - let old = nodes[idx]; - let prev = nodes[(idx - 1 + nodes.length) % nodes.length]; + const old = nodes[idx]; + const prev = nodes[(idx - 1 + nodes.length) % nodes.length]; yield* setFocused(old, false, self); yield* setFocused(prev, true, self); @@ -89,10 +89,10 @@ export const FocusApi: Api = createApi("freedom:focus", { } if (target.props.focused === true) return; - let self = yield* NodeContext.expect(); - let r = findRoot(target); - let nodes = focusChain(r); - let old = nodes.find((n) => n.props.focused === true); + const self = yield* NodeContext.expect(); + const r = findRoot(target); + const nodes = focusChain(r); + const old = nodes.find((n) => n.props.focused === true); if (old) { yield* setFocused(old, false, self); @@ -101,10 +101,10 @@ export const FocusApi: Api = createApi("freedom:focus", { }, *current() { - let node = yield* NodeContext.expect(); - let r = findRoot(node); - let nodes = focusChain(r); - let focused = nodes.find((n) => n.props.focused === true); + const node = yield* NodeContext.expect(); + const r = findRoot(node); + const nodes = focusChain(r); + const focused = nodes.find((n) => n.props.focused === true); if (focused) { return focused; } else { diff --git a/packages/freedom/src/lib/freedom.ts b/packages/freedom/src/lib/freedom.ts index 2cc9d0c..1c43c02 100644 --- a/packages/freedom/src/lib/freedom.ts +++ b/packages/freedom/src/lib/freedom.ts @@ -1,3 +1,4 @@ +// oxlint-disable require-yield import { type Api, type Operation, @@ -41,38 +42,38 @@ export const FreedomApi: Api = createApi("freedom:node", { useNode: () => NodeContext.expect(), *get(key: string): Operation { - let node = yield* NodeContext.expect(); + const node = yield* NodeContext.expect(); return node._props[key]; }, *set(key: string, value: JsonValue) { validateJsonValue(value); - let node = yield* NodeContext.expect(); + const node = yield* NodeContext.expect(); node._props[key] = value; }, *update(key: string, fn: (prev: JsonValue | undefined) => JsonValue) { - let node = yield* NodeContext.expect(); - let prev = node._props[key]; - let next = fn(prev); + const node = yield* NodeContext.expect(); + const prev = node._props[key]; + const next = fn(prev); validateJsonValue(next); node._props[key] = next; }, *unset(key: string) { - let node = yield* NodeContext.expect(); + const node = yield* NodeContext.expect(); if (key in node._props) { delete node._props[key]; } }, *append(name: string, component: Component): Operation { - let parent = yield* NodeContext.expect(); - let tree = yield* TreeContext.expect(); - let child = new NodeImpl(tree.nextId(), name, parent); - let ready = withResolvers(); + const parent = yield* NodeContext.expect(); + const tree = yield* TreeContext.expect(); + const child = new NodeImpl(tree.nextId(), name, parent); + const ready = withResolvers(); - let task = yield* spawn(function* () { + const task = yield* spawn(function* () { parent._children.add(child); tree.nodes.set(child.id, child); yield* NodeContext.set(child); @@ -95,12 +96,12 @@ export const FreedomApi: Api = createApi("freedom:node", { }, *remove(node: Node) { - let halt = node.data.expect(Halt); + const halt = node.data.expect(Halt); yield* halt(); }, *sort(fn: ((a: Node, b: Node) => number) | undefined) { - let node = yield* NodeContext.expect(); + const node = yield* NodeContext.expect(); node._sortFn = fn; }, }); diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index a6d1df6..a85628a 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -25,7 +25,7 @@ class NodeDataImpl implements NodeData { } expect(key: NodeDataKey): T { - let val = this._map.get(key.symbol); + const val = this._map.get(key.symbol); if (val !== undefined) { return val as T; } else if (key.defaultValue !== undefined) { @@ -72,10 +72,10 @@ export class NodeImpl implements Node { get children(): Iterable { if (this._sortFn) { - let fn = this._sortFn; - let indexed = [...this._children].map((c, i) => [c, i] as const); + const fn = this._sortFn; + const indexed = [...this._children].map((c, i) => [c, i] as const); indexed.sort(([a, ai], [b, bi]) => { - let result = fn(a, b); + const result = fn(a, b); if (result !== 0) { return result; } else { @@ -93,7 +93,7 @@ export class NodeImpl implements Node { } *eval(op: () => Operation): Operation> { - let resolver = withResolvers>(); + const resolver = withResolvers>(); yield* this._channel.send({ resolve: resolver.resolve as (result: Result) => void, operation: op as () => Operation, @@ -109,19 +109,19 @@ export class NodeImpl implements Node { export function* spawnEvalLoop( channel: Channel, ): Operation { - let ready = withResolvers(); + const ready = withResolvers(); yield* spawn(function* () { - let sub = yield* channel as Stream; + const sub = yield* channel as Stream; ready.resolve(); while (true) { - let next = yield* sub.next(); + const next = yield* sub.next(); if (next.done) { break; } - let call = next.value; - let result = yield* box(call.operation); + const call = next.value; + const result = yield* box(call.operation); call.resolve(result); } }); diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts index 35755ca..4f73225 100644 --- a/packages/freedom/src/lib/tree.ts +++ b/packages/freedom/src/lib/tree.ts @@ -14,11 +14,11 @@ import { FreedomApi } from "./freedom.ts"; export function useTree(root: Component): Operation { return resource(function* (provide) { - let output = createSignal(); - let events = createSignal(); + const output = createSignal(); + const events = createSignal(); let counter = 0; - let state: TreeState = { + const state: TreeState = { dirty: false, output, events, @@ -33,11 +33,11 @@ export function useTree(root: Component): Operation { yield* TreeContext.set(state); - let rootNode = new NodeImpl(state.nextId(), "", undefined); + const rootNode = new NodeImpl(state.nextId(), "", undefined); rootNode.remove = () => FreedomApi.operations.remove(rootNode); state.nodes.set(rootNode.id, rootNode); - let ready = withResolvers(); + const ready = withResolvers(); // Spawn root node scope yield* spawn(function* () { @@ -58,7 +58,7 @@ export function useTree(root: Component): Operation { state.markDirty(); }, *append(args, next) { - let node = yield* next(...args); + const node = yield* next(...args); state.markDirty(); return node; }, @@ -75,14 +75,14 @@ export function useTree(root: Component): Operation { yield* spawnEvalLoop(rootNode._channel); // Subscribe to events, then spawn the event loop - let sub = yield* events; + const sub = yield* events; yield* spawn(function* () { while (true) { - let next = yield* sub.next(); + const next = yield* sub.next(); if (next.done) { break; } - let event = next.value; + const event = next.value; state.dirty = false; yield* rootNode.eval(() => DispatchApi.operations.dispatch(event)); if (state.dirty) { @@ -99,7 +99,7 @@ export function useTree(root: Component): Operation { yield* ready.operation; - let tree: Tree = { + const tree: Tree = { dispatch(event: unknown) { events.send(event); }, diff --git a/packages/freedom/src/lib/validate.ts b/packages/freedom/src/lib/validate.ts index e37f9f9..3fa1f6d 100644 --- a/packages/freedom/src/lib/validate.ts +++ b/packages/freedom/src/lib/validate.ts @@ -40,13 +40,13 @@ export function validateJsonValue(value: unknown): asserts value is JsonValue { throw new Error("RegExp instances are not valid JsonValues"); } if (Array.isArray(value)) { - for (let item of value) { + for (const item of value) { validateJsonValue(item); } return; } if (typeof value === "object" && value !== null) { - for (let key of Object.keys(value)) { + for (const key of Object.keys(value)) { validateJsonValue((value as Record)[key]); } return; From 96e0bae31e3dcf6efe9d6b7d23f7afc0e1c50ca7 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 23:53:44 -0500 Subject: [PATCH 05/47] =?UTF-8?q?=F0=9F=8E=A8=20disable=20no-generic-error?= =?UTF-8?q?=20and=20max-params=20in=20impacted=20freedom=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/src/lib/focus.ts | 2 ++ packages/freedom/src/lib/freedom.ts | 1 + packages/freedom/src/lib/node.ts | 2 ++ packages/freedom/src/lib/validate.ts | 1 + 4 files changed, 6 insertions(+) diff --git a/packages/freedom/src/lib/focus.ts b/packages/freedom/src/lib/focus.ts index 3bd246a..a30effa 100644 --- a/packages/freedom/src/lib/focus.ts +++ b/packages/freedom/src/lib/focus.ts @@ -1,3 +1,5 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params import type { Api, Operation } from "effection"; import { createApi } from "effection/experimental"; import type { Node } from "./types.ts"; diff --git a/packages/freedom/src/lib/freedom.ts b/packages/freedom/src/lib/freedom.ts index 1c43c02..bee5080 100644 --- a/packages/freedom/src/lib/freedom.ts +++ b/packages/freedom/src/lib/freedom.ts @@ -1,4 +1,5 @@ // oxlint-disable require-yield +// oxlint-disable bombshell-dev/no-generic-error import { type Api, type Operation, diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index a85628a..f6a7734 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -1,3 +1,5 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params import { type Channel, type Context, diff --git a/packages/freedom/src/lib/validate.ts b/packages/freedom/src/lib/validate.ts index 3fa1f6d..d14c524 100644 --- a/packages/freedom/src/lib/validate.ts +++ b/packages/freedom/src/lib/validate.ts @@ -1,3 +1,4 @@ +// oxlint-disable bombshell-dev/no-generic-error import type { JsonValue } from "./types.ts"; export function validateJsonValue(value: unknown): asserts value is JsonValue { From d105cbcc00d2cb60763bcc130b528fbd01df0f31 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 23:54:48 -0500 Subject: [PATCH 06/47] =?UTF-8?q?=F0=9F=90=9B=20point=20exports=20at=20bui?= =?UTF-8?q?lt=20.mjs=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/freedom/package.json b/packages/freedom/package.json index 19d39b3..d86f1b8 100644 --- a/packages/freedom/package.json +++ b/packages/freedom/package.json @@ -11,7 +11,7 @@ }, "exports": { ".": { - "import": "./dist/index.js" + "import": "./dist/index.mjs" }, "./package.json": "./package.json" }, From 08069f8ff20d81fa1a831c34779b920127b2e40d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 24 Jun 2026 23:55:17 -0500 Subject: [PATCH 07/47] =?UTF-8?q?=F0=9F=93=8C=20lock=20effection=20for=20@?= =?UTF-8?q?bomb.sh/freedom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 283 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 281 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0654b8..3144b71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,25 @@ importers: specifier: latest version: 0.5.3(@types/node@26.0.0)(oxc-resolver@11.21.3)(vite@8.1.0(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) + packages/freedom: + dependencies: + effection: + specifier: 4.1.0-alpha.9 + version: 4.1.0-alpha.9 + devDependencies: + '@bomb.sh/tools': + specifier: latest + version: 0.5.4(@types/node@26.0.0)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) + '@types/node': + specifier: ^26.0.0 + version: 26.0.0 + packages: + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@8.0.0-rc.3': resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -68,6 +85,10 @@ packages: resolution: {integrity: sha512-665My/wDcVUKFWGy4O4MP6LfuCxd8dITRfahc2UIpufq5wtqJ+1MWuNDkMFpC6jY7/GWOWG11urB1v2KkV2Jtg==} hasBin: true + '@bomb.sh/tools@0.5.4': + resolution: {integrity: sha512-OGbhambOn0o2w9LX5N4MoyMBxVLXByD1aCIFlAbQvCbLt60sMffjy1FbGMJtnpcwSr6TePWBKnGC7OaU1r29wg==} + hasBin: true + '@bomb.sh/tty@0.7.0': resolution: {integrity: sha512-htTavc3o84vR6uSoyF+Y9u81GG8NoTJkwOovahcNOY4nVeiM86vodCHdPvAXoj2ucmx04frpwMrOJ4ipLSSJfw==} engines: {node: '>= 22'} @@ -797,47 +818,94 @@ packages: cpu: [arm64] os: [darwin] + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-8AX9NwC+G6Sbh5hNLnx8YgxoRV/8BH8FQRtZ86OTtUQfESMRvwszOGTtfcC32g86O5jsEQPDHXKVSnsIzWh6lg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260427.1': resolution: {integrity: sha512-6MjekGfajPtny/bBoBYJ+8dTOlgw6nhSSgJ3Us4R/4L8R90ll803Krz+iz907r1SnYeK5eWubDMV/p1ryLNXkQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-ef+JmOjtTu0BRdhAq69WbzCWjZsG6gqS8xDI4fKABdWXlrVojPkrD2OObog3PC7Qte8us6QspQvvnKTf7yKFSQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260427.1': resolution: {integrity: sha512-a1yG/vrLaN3dORvaMuNqXz5jcTaTEPBfhmq77vzqRn8As7EdqxtizPosfxB9K1s7PEB8NeGQKqHEQroPUCsPFg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-xb9m0WXvIPHOogGTC02iBv3cwRJ7q+Ql2ABcsu4kHYc1p02JSbIBEfu3g58TpcFZyODsO+8WaDylx/hBiGzlVw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + '@typescript/native-preview-linux-arm@7.0.0-dev.20260427.1': resolution: {integrity: sha512-3bhv/NxU9FHIN3MSmoplIAkIHF62mlF9l5XooAFawwj8yscvPZih/m5fkYIiP5qGri3828XwGyT1Cksaft6FWQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] + '@typescript/native-preview-linux-arm@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-8f9dT1RaOK3Wy/puboAcCik4HBX6mfWt5hOMipWehDfInNFI6XP3ZYErShowBQ0Tkln5qdGqDIoq452PO/ZwCA==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + '@typescript/native-preview-linux-x64@7.0.0-dev.20260427.1': resolution: {integrity: sha512-lqaA9oF9ZSw1jn87+Ncxo0Sf0d65eVXMjAD0z44ne7QKFRgWd+QpvK4AXAG4lxnFR+XdndWlVm6O1/tdvcG7xQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] + '@typescript/native-preview-linux-x64@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-AZRp6q0SyVw9xe9kpyfTmavqkQKizDhmOLglDHbxAu4Y3inL6r2GSE7q3w/eO4R259dIwl5TM52DZKK8onoT6Q==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260427.1': resolution: {integrity: sha512-ZGXRDC0WPVK/Ky2fZRhy2EcNmdHg22biVYWcWgOUK5tCbJd/KJs3VXk758gn0UbFHEQAR5d7dsvDucCCjZkWpA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-DXRMXOe/JEmf2CULZZn1xvRuJrnZ9DxzDubxtW85pE0aSLgl+VuplWCsZrBxaCZDrWMRIfMDV8Y0y+Yftx/jeQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + '@typescript/native-preview-win32-x64@7.0.0-dev.20260427.1': resolution: {integrity: sha512-Ut4Hncq1IuSeNIfcPs1s719j8H3ZA+ogsJ53W3s/Wy1UF5BIhu5Hkspdc7TzGgJgYqGJKo/+pr4vsRnbBPdWgQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] + '@typescript/native-preview-win32-x64@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-kYB1I6tdxWHjhSt8B2v9CHmpOJwJvD88LUCxiBS9C59pwrGe+LAwZvbTTJxvULNaUEjxdiMomYerWWhD/09Y3g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typescript/native-preview@7.0.0-dev.20260427.1': resolution: {integrity: sha512-g6L7hed1Y2OGwAzZ+vXoGSvtJUdWUtTqtsn/16+UjYbu3+6pol0cggdWj26SFxI41R+jLfnT2+JGtoXRBdH+RQ==} engines: {node: '>=16.20.0'} hasBin: true + '@typescript/native-preview@7.0.0-dev.20260623.1': + resolution: {integrity: sha512-33AkAhmRu1/w4nRFLnJ9lic7FSzW4zWfU1u5DP7A2+j1445SSNvf3Q4WGtDzdAR5HSlk8vXEa0B4xUHR5vM1tg==} + engines: {node: '>=16.20.0'} + hasBin: true + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -909,6 +977,19 @@ packages: oxc-resolver: optional: true + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effection@4.1.0-alpha.9: + resolution: {integrity: sha512-vjZ2RRoXagbjuPin21TCusmO6VScPT7Kru/LWiE8t78MsQJA8qRmzaGTGw2PFIiHcfzrrXpgMvHXUy0aW3/Uhg==} + engines: {node: '>= 16'} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -948,6 +1029,10 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -955,6 +1040,10 @@ packages: resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} engines: {node: '>=20.19.0'} + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1127,6 +1216,25 @@ packages: vue-tsc: optional: true + rolldown-plugin-dts@0.26.0: + resolution: {integrity: sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1218,6 +1326,40 @@ packages: unplugin-unused: optional: true + tsdown@0.22.3: + resolution: {integrity: sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.3 + '@tsdown/exe': 0.22.3 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1352,9 +1494,18 @@ packages: snapshots: + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.0 + '@babel/types': 8.0.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/generator@8.0.0-rc.3': dependencies: - '@babel/parser': 8.0.0-rc.3 + '@babel/parser': 8.0.0 '@babel/types': 8.0.0-rc.3 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -1383,7 +1534,7 @@ snapshots: '@babel/types@8.0.0-rc.3': dependencies: '@babel/helper-string-parser': 8.0.0 - '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.2 '@bomb.sh/args@0.3.1': {} @@ -1429,6 +1580,49 @@ snapshots: - vite-plus - vue-tsc + '@bomb.sh/tools@0.5.4(@types/node@26.0.0)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@bomb.sh/args': 0.3.1 + '@humanfs/node': 0.16.8 + '@humanfs/types': 0.15.0 + '@typescript/native-preview': 7.0.0-dev.20260623.1 + knip: 6.18.0 + oxfmt: 0.47.0 + oxlint: 1.71.0 + publint: 0.3.21 + tinyexec: 1.2.4 + tsdown: 0.22.3(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(unrun@0.2.39) + ultramatter: 0.0.4 + vitest: 4.1.9(@types/node@26.0.0)(vite@8.1.0(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) + vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@26.0.0)(vite@8.1.0(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0))) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-preview' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - happy-dom + - jsdom + - msw + - oxc-resolver + - oxlint-tsgolint + - tsx + - typescript + - unplugin-unused + - unrun + - vite + - vite-plus + - vue-tsc + '@bomb.sh/tty@0.7.0': {} '@emnapi/core@1.10.0': @@ -1901,24 +2095,45 @@ snapshots: '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview-linux-arm@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-linux-arm@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview-linux-x64@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-linux-x64@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview-win32-x64@7.0.0-dev.20260427.1': optional: true + '@typescript/native-preview-win32-x64@7.0.0-dev.20260623.1': + optional: true + '@typescript/native-preview@7.0.0-dev.20260427.1': optionalDependencies: '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260427.1 @@ -1929,6 +2144,16 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260427.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260427.1 + '@typescript/native-preview@7.0.0-dev.20260623.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260623.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260623.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260623.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260623.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260623.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260623.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260623.1 + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -1996,6 +2221,12 @@ snapshots: optionalDependencies: oxc-resolver: 11.21.3 + dts-resolver@3.0.0(oxc-resolver@11.21.3): + optionalDependencies: + oxc-resolver: 11.21.3 + + effection@4.1.0-alpha.9: {} + empathic@2.0.1: {} es-module-lexer@2.1.0: {} @@ -2025,10 +2256,16 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + hookable@6.1.1: {} import-without-cache@0.3.3: {} + import-without-cache@0.4.0: {} + jiti@2.7.0: {} jsesc@3.1.0: {} @@ -2244,6 +2481,22 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown-plugin-dts@0.26.0(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.2): + dependencies: + '@babel/generator': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@babel/parser': 8.0.0 + ast-kit: 3.0.0 + birpc: 4.0.0 + dts-resolver: 3.0.0(oxc-resolver@11.21.3) + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.3 + rolldown: 1.1.2 + optionalDependencies: + '@typescript/native-preview': 7.0.0-dev.20260623.1 + transitivePeerDependencies: + - oxc-resolver + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -2346,6 +2599,32 @@ snapshots: - synckit - vue-tsc + tsdown@0.22.3(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(unrun@0.2.39): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.3 + picomatch: 4.0.4 + rolldown: 1.1.2 + rolldown-plugin-dts: 0.26.0(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.2) + semver: 7.8.5 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + optionalDependencies: + publint: 0.3.21 + unrun: 0.2.39 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - vue-tsc + tslib@2.8.1: optional: true From 6f9e1614358f191823964cc99eb994732172c8c0 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 26 Jun 2026 10:27:15 +0300 Subject: [PATCH 08/47] =?UTF-8?q?=F0=9F=90=9B=20fix=20eval=20re-entrancy?= =?UTF-8?q?=20detection=20to=20scope=20dispatch=20middleware=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-eval deadlock avoidance used NodeContext to detect re-entrant calls, but NodeContext spans the whole node scope — including the root event loop task — so the event loop's dispatch wrongly short-circuited into its own scope, where middleware (registered in the eval-loop scope) was invisible. Use a dedicated EvalOwner context set inside spawnEvalLoop, which is present only while executing within that loop. Genuine re-entrant self-evals still run inline; the event loop's dispatch routes through the channel as before. Adds a regression test for re-entrant self-eval (previously uncovered). --- packages/freedom/src/lib/freedom.ts | 2 +- packages/freedom/src/lib/node.ts | 20 ++++++++++++++++---- packages/freedom/src/lib/tree.ts | 2 +- packages/freedom/test/freedom.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/freedom/src/lib/freedom.ts b/packages/freedom/src/lib/freedom.ts index bee5080..db3a8a5 100644 --- a/packages/freedom/src/lib/freedom.ts +++ b/packages/freedom/src/lib/freedom.ts @@ -78,7 +78,7 @@ export const FreedomApi: Api = createApi("freedom:node", { parent._children.add(child); tree.nodes.set(child.id, child); yield* NodeContext.set(child); - yield* spawnEvalLoop(child._channel); + yield* spawnEvalLoop(child); ready.resolve(); try { yield* component(); diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index f6a7734..eacbb31 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -95,6 +95,12 @@ export class NodeImpl implements Node { } *eval(op: () => Operation): Operation> { + // Re-entrant call from inside this node's own eval loop: running through + // the channel would deadlock (the loop is busy awaiting us), so run inline. + const owner = yield* EvalOwner.get(); + if (owner === this) { + return yield* box(op); + } const resolver = withResolvers>(); yield* this._channel.send({ resolve: resolver.resolve as (result: Result) => void, @@ -108,13 +114,14 @@ export class NodeImpl implements Node { } } -export function* spawnEvalLoop( - channel: Channel, -): Operation { +export function* spawnEvalLoop(node: NodeImpl): Operation { const ready = withResolvers(); yield* spawn(function* () { - const sub = yield* channel as Stream; + const sub = yield* node._channel as Stream; + // Mark this task's scope as the owner of node's eval loop, so a re-entrant + // node.eval() running within it short-circuits inline instead of deadlocking. + yield* EvalOwner.set(node); ready.resolve(); while (true) { @@ -134,3 +141,8 @@ export function* spawnEvalLoop( export const NodeContext: Context = createContext( "freedom:current-node", ); + +// Set within a node's eval loop to identify re-entrant self-eval calls. +const EvalOwner: Context = createContext( + "freedom:eval-owner", +); diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts index 4f73225..ee2a562 100644 --- a/packages/freedom/src/lib/tree.ts +++ b/packages/freedom/src/lib/tree.ts @@ -72,7 +72,7 @@ export function useTree(root: Component): Operation { }, }); - yield* spawnEvalLoop(rootNode._channel); + yield* spawnEvalLoop(rootNode); // Subscribe to events, then spawn the event loop const sub = yield* events; diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 1148ad2..96196ca 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -298,6 +298,28 @@ describe("Node.eval", () => { } }); }); + + it("re-entrant eval on the current node runs inline without deadlock", async () => { + await run(function* () { + let tree = yield* useTree(function* () {}); + + let result = yield* tree.root.eval(function* () { + // Re-entrant: eval the current node from inside its own eval loop. + // Routing through the channel here would deadlock. + let inner = yield* tree.root.eval(function* () { + yield* set("inner", true); + return "nested"; + }); + return inner; + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({ ok: true, value: "nested" }); + } + expect(tree.root.props["inner"]).toEqual(true); + }); + }); }); describe("Tree and notification", () => { From c0880c1d69f17d696a365926ebbe6ff46ddd4722 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 26 Jun 2026 10:56:54 +0300 Subject: [PATCH 09/47] =?UTF-8?q?=E2=9E=95=20add=20@bomb.sh/freedom=20dep?= =?UTF-8?q?=20to=20demo=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/demo/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/demo/package.json b/packages/demo/package.json index eecc05f..09ce3fb 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -23,6 +23,7 @@ "test": "bsh test" }, "dependencies": { + "@bomb.sh/freedom": "workspace:*", "@bomb.sh/tty": "latest", "@types/node": "^26.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3144b71..1c8817b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: packages/demo: dependencies: + '@bomb.sh/freedom': + specifier: workspace:* + version: link:../freedom '@bomb.sh/tty': specifier: latest version: 0.7.0 From c20e9f3aa622d2126475ae93de38583e9f290953 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 26 Jun 2026 11:00:02 +0300 Subject: [PATCH 10/47] =?UTF-8?q?=E2=9C=A8=20add=20stdin/input=20Effection?= =?UTF-8?q?=20helpers=20to=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/demo/package.json | 3 +- packages/demo/src/use-input.ts | 70 ++++++++++++++++++++++++++++++++++ packages/demo/src/use-stdin.ts | 37 ++++++++++++++++++ pnpm-lock.yaml | 3 ++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/demo/src/use-input.ts create mode 100644 packages/demo/src/use-stdin.ts diff --git a/packages/demo/package.json b/packages/demo/package.json index 09ce3fb..b8ede02 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -25,7 +25,8 @@ "dependencies": { "@bomb.sh/freedom": "workspace:*", "@bomb.sh/tty": "latest", - "@types/node": "^26.0.0" + "@types/node": "^26.0.0", + "effection": "4.1.0-alpha.9" }, "devDependencies": { "@bomb.sh/tools": "latest" diff --git a/packages/demo/src/use-input.ts b/packages/demo/src/use-input.ts new file mode 100644 index 0000000..2606c56 --- /dev/null +++ b/packages/demo/src/use-input.ts @@ -0,0 +1,70 @@ +import { + call, + createChannel, + each, + type Operation, + race, + resource, + sleep, + spawn, + type Stream, + suspend, + until, +} from "effection"; +import { + createInput, + type InputEvent, + type InputOptions, +} from "@bomb.sh/tty"; + +function nothing() { + return suspend() as unknown as Operation< + IteratorResult + >; +} + +// Parse a raw byte Stream into a Stream of decoded terminal InputEvents. +export function useInput( + stream: Stream, + options?: InputOptions, +): Stream { + return resource(function* (provide) { + const input = yield* until(createInput(options)); + const subscription = yield* stream; + + let pending = nothing(); + + const events = createChannel(); + + yield* spawn(function* () { + let next = yield* subscription.next(); + while (!next.done) { + const result = input.scan(next.value); + pending = result.pending ? rescan(result.pending.delay) : nothing(); + for (const event of result.events) { + yield* events.send(event); + } + next = yield* race([subscription.next(), pending]); + } + yield* events.close(); + }); + + yield* race([provide(yield* events), drain(events)]); + }); +} + +function rescan(delay: number): ReturnType { + return call(function* (): Operation> { + yield* sleep(delay); + return { + done: false, + value: new Uint8Array(), + }; + }); +} + +function* drain(stream: Stream): Operation { + for (const _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/packages/demo/src/use-stdin.ts b/packages/demo/src/use-stdin.ts new file mode 100644 index 0000000..eb2c9ca --- /dev/null +++ b/packages/demo/src/use-stdin.ts @@ -0,0 +1,37 @@ +import { + createChannel, + each, + type Operation, + race, + resource, + spawn, + type Stream, + until, +} from "effection"; +import { stdin } from "node:process"; + +// Bridge Node's process.stdin (raw bytes) into an Effection Stream. +export function useStdin(): Operation> { + return resource(function* (provide) { + const channel = createChannel(); + + const iterator = stdin[Symbol.asyncIterator](); + + yield* spawn(function* () { + let next = yield* until(iterator.next()); + while (!next.done) { + yield* channel.send(next.value); + next = yield* until(iterator.next()); + } + yield* channel.close(); + }); + + yield* race([provide(channel), drain(channel)]); + }); +} + +function* drain(stream: Stream): Operation { + for (const _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c8817b..ba2fab1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 + effection: + specifier: 4.1.0-alpha.9 + version: 4.1.0-alpha.9 devDependencies: '@bomb.sh/tools': specifier: latest From 7a1aac8c9e6adc3ebdda9a5cd89172a83bfc27cd Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 26 Jun 2026 11:02:00 +0300 Subject: [PATCH 11/47] =?UTF-8?q?=E2=9C=A8=20port=20freedom=20focus/text-i?= =?UTF-8?q?nput=20demo=20to=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/demo/src/freedom-focus-text-input.ts | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 packages/demo/src/freedom-focus-text-input.ts diff --git a/packages/demo/src/freedom-focus-text-input.ts b/packages/demo/src/freedom-focus-text-input.ts new file mode 100644 index 0000000..a54b16f --- /dev/null +++ b/packages/demo/src/freedom-focus-text-input.ts @@ -0,0 +1,298 @@ +// oxlint-disable require-yield +import { each, ensure, main, type Operation, spawn, until } from "effection"; +import { createApi } from "effection/experimental"; +import { + advance, + append, + createNodeData, + current, + DispatchApi, + focusable, + type Node, + retreat, + set, + type Tree, + update, + useFocus, + useNode, + useTree, +} from "@bomb.sh/freedom"; +import { + alternateBuffer, + close, + createTerm, + cursor, + fit, + grow, + type KeyDown, + type KeyEvent, + type KeyRepeat, + type KeyUp, + type Op, + open, + percent, + rgba, + settings, + text, +} from "@bomb.sh/tty"; +import { stdin, stdout } from "node:process"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const GRAY = rgba(100, 100, 100); + +const InputApi = createApi("demo:input", { + *keydown(event: KeyDown): Operation { + if (event.code === "Tab") { + yield* advance(); + } else if (event.code === "Backtab") { + yield* retreat(); + } + }, + *keyup(_event: KeyUp): Operation { + // no-op + }, + *keyrepeat(event: KeyRepeat): Operation { + if (event.code === "Tab") { + yield* advance(); + } else if (event.code === "Backtab") { + yield* retreat(); + } + }, +}); + +function onkeydown( + handler: ( + event: KeyDown, + next: (event: KeyDown) => Operation, + ) => Operation, +): Operation { + return InputApi.around({ + keydown([event], next) { + return handler(event, next); + }, + }); +} + +interface LayoutOptions { + node: Node; + children: Iterable; +} + +const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>( + "demo:layout", + () => [], +); + +function* layout(body: (props: LayoutOptions) => Op[]): Operation { + const node = yield* useNode(); + node.data.set(layoutKey, body); +} + +function* useTextInput(): Operation { + yield* focusable(); + yield* set("value", ""); + yield* onkeydown(function* (event, next) { + if (event.key.length === 1) { + yield* update("value", (v) => `${v ?? ""}${event.key}`); + } else if (event.code === "Backspace") { + yield* update("value", (v) => { + const str = String(v ?? ""); + return str.slice(0, -1); + }); + } else { + yield* next(event); + } + }); +} + +await main(function* () { + const tree = yield* useTree(function* () { + yield* useFocus(); + yield* layout(({ node, children }) => { + return [ + open(node.id, { + layout: { + height: grow(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + border: { + color: rgba(255, 255, 255), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + }), + ...children, + close(), + ]; + }); + + yield* DispatchApi.around({ + *dispatch([event], next) { + if (isKeyboardEvent(event)) { + const focus = yield* current(); + const result = yield* focus.eval(function* () { + const handler = InputApi.operations[event.type]; + yield* handler(event as KeyDown & KeyUp & KeyRepeat); + }); + return result.ok ? { ok: true, value: true } : result; + } + return yield* next(event); + }, + }); + + yield* append("input-1", function* () { + yield* layout(({ node, children }) => { + return [ + open(node.id, { + border: { color: 0xFFF, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + height: fit(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + ...children, + close(), + ]; + }); + + yield* append("input-1-1", function* () { + yield* useTextInput(); + yield* layout(({ node }) => { + const color = node.props.focused ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.props.value ?? "")), + close(), + ]; + }); + }); + + yield* append("input-1-2", function* () { + yield* useTextInput(); + yield* layout(({ node }) => { + const color = node.props.focused ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.props.value ?? "")), + close(), + ]; + }); + }); + }); + + yield* append("input-2", function* () { + yield* useTextInput(); + yield* layout(({ node }) => { + const color = node.props.focused ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.props.value ?? "")), + close(), + ]; + }); + }); + }); + + const { columns, rows } = stdout.isTTY + ? { columns: stdout.columns, rows: stdout.rows } + : { columns: 80, rows: 24 }; + + stdin.setRawMode(true); + yield* ensure(() => { + stdin.setRawMode(false); + stdin.pause(); + }); + + const bytes = yield* useStdin(); + const input = useInput(bytes); + + let term = yield* until(createTerm({ height: rows, width: columns })); + + const events = yield* spawn(function* () { + for (const event of yield* each(input)) { + if (event.type === "keydown" && event.ctrl && event.code === "c") { + break; + } + if (event.type === "resize") { + term = yield* until(createTerm({ + height: event.height, + width: event.width, + })); + } + + tree.dispatch(event); + + yield* each.next(); + } + }); + + function render(tree: Tree) { + const ops = walk(tree.root); + const { output } = term.render(ops); + stdout.write(output); + } + + const tty = settings(cursor(false), alternateBuffer()); + + try { + stdout.write(tty.apply); + + render(tree); + yield* spawn(function* () { + for (const _ of yield* each(tree)) { + render(tree); + yield* each.next(); + } + }); + + yield* events; + } finally { + stdout.write(tty.revert); + } +}); + +function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const body = node.data.get(layoutKey); + return body ? body({ node, children }) : children; +} + +function isKeyboardEvent(event: unknown): event is KeyEvent { + const x = event as KeyEvent; + return !!x && typeof x.key === "string" && typeof x.code === "string" && + ["keyup", "keydown", "keyrepeat"].includes(x.type); +} From 3a149adbcde1847e2f460e84d734031460f84849 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 26 Jun 2026 11:15:18 +0300 Subject: [PATCH 12/47] =?UTF-8?q?=E2=9C=A8=20add=20`pnpm=20demo=20`?= =?UTF-8?q?=20runner=20with=20TTY=20inheritance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- packages/demo/src/freedom-focus-text-input.ts | 5 +++++ scripts/demo.mjs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 scripts/demo.mjs diff --git a/package.json b/package.json index 7ac8795..4b5299d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "build": "bsh build", "format": "bsh format", "lint": "bsh lint", - "test": "bsh test" + "test": "bsh test", + "demo": "node scripts/demo.mjs" }, "devDependencies": { "@bomb.sh/tools": "latest" diff --git a/packages/demo/src/freedom-focus-text-input.ts b/packages/demo/src/freedom-focus-text-input.ts index a54b16f..eee676c 100644 --- a/packages/demo/src/freedom-focus-text-input.ts +++ b/packages/demo/src/freedom-focus-text-input.ts @@ -1,4 +1,5 @@ // oxlint-disable require-yield +// oxlint-disable bombshell-dev/no-generic-error import { each, ensure, main, type Operation, spawn, until } from "effection"; import { createApi } from "effection/experimental"; import { @@ -107,6 +108,10 @@ function* useTextInput(): Operation { } await main(function* () { + if (!stdin.isTTY) { + throw new Error("freedom demo requires an interactive TTY"); + } + const tree = yield* useTree(function* () { yield* useFocus(); yield* layout(({ node, children }) => { diff --git a/scripts/demo.mjs b/scripts/demo.mjs new file mode 100644 index 0000000..54d17fb --- /dev/null +++ b/scripts/demo.mjs @@ -0,0 +1,14 @@ +import { spawnSync } from "node:child_process"; + +const name = process.argv[2]; +if (!name) { + console.error("usage: pnpm demo (e.g. pnpm demo freedom-focus-text-input)"); + process.exit(1); +} + +const { status } = spawnSync( + "node", + ["--experimental-transform-types", "--no-warnings", `packages/demo/src/${name}.ts`], + { stdio: "inherit" }, // inherit the real TTY: setRawMode works, ANSI renders cleanly +); +process.exit(status ?? 0); From 0758092febafa422116437242178cef0cc78b21d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 27 Jun 2026 00:11:07 +0300 Subject: [PATCH 13/47] =?UTF-8?q?=F0=9F=93=9D=20spec:=20event-driven=20foc?= =?UTF-8?q?us=20via=20eval-event=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/specs/freedom-focus-spec.md | 49 +++++++++++++------- packages/freedom/specs/freedom-spec.md | 37 +++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/packages/freedom/specs/freedom-focus-spec.md b/packages/freedom/specs/freedom-focus-spec.md index fd5831b..f947851 100644 --- a/packages/freedom/specs/freedom-focus-spec.md +++ b/packages/freedom/specs/freedom-focus-spec.md @@ -96,18 +96,31 @@ from becoming focusable. **advance** -F4. `advance()` moves focus to the next focusable node in the focus chain (§5). +F4. `advance()` *requests* a move to the next focusable node in the focus chain +(§5) by enqueuing an eval-event (Freedom spec §7.5). The move is applied when +that event is drained, on a subsequent dispatch cycle; when the eval-event +completes successfully (observable via its `callback`), focus has moved. F5. If the currently focused node is the last in the focus chain, `advance()` wraps to the first focusable node. -F6. Focus movement sets the old node's `focused` property to `false` and the new -node's `focused` property to `true`, using `node.eval()` to run property -operations in each node's scope (§6.2). +F6. Focus movement is performed by the eval-event handler, which runs the +`FocusApi` operation in the target node's scope, setting the old node's +`focused` property to `false` and the new node's to `true`. Because the handler +runs in its own idle cycle, these writes use the target nodes' free eval loops +without deadlock. F7. If the focus chain contains only one focusable node (including root), `advance()` is a no-op. +F7a. `advance`, `retreat`, and `focus` are **deferred**: each resolves once its +eval-event is *enqueued*, not once focus has moved. The move itself is observable +via the eval-event `callback` — a `callback` receiving `{ ok: true }` means focus +has moved; an `{ ok: false }` means it did not (e.g. a non-focusable target). +`current()` and `focusable()` remain synchronous. Awaiting completion from +*inside* the triggering dispatch is impossible by construction (it would re-create +the deadlock the deferral exists to avoid), so the public ops resolve on enqueue. + **retreat** F8. `retreat()` moves focus to the previous focusable node in the focus chain @@ -227,29 +240,33 @@ occur within the same dispatch cycle. ### 6.3 Focused node removal -FL7. `useFocus()` installs middleware on the `remove` context API operation that -advances focus before a focused node is destroyed: +FL7. `useFocus()` installs middleware on the `remove` context API operation +that, when the node being removed is focused, computes its successor in the focus +chain **synchronously (before teardown)** and enqueues `focus(successor)`, then +calls `next(node)`: ```ts yield* FreedomApi.around({ *remove([node], next) { - let focused = yield* node.eval(() => get("focused")); - if (focused === true) { - yield* FocusApi.operations.advance(); + if (node.props.focused === true) { + let successor = successorOf(node); // sync chain walk, excludes node + if (successor && successor !== node) { + yield* focus(successor); // deferred eval-event + } } yield* next(node); }, }); ``` -FL8. If the removed node is the only focusable node (i.e., it is the root), -`advance()` is a no-op (F7) and focus remains on root. This case should not -arise in practice because `remove` on the root is an error (C-rm3). +FL8. If the only focusable node is root, there is no successor and no focus event +is enqueued. This case should not arise in practice because `remove` on the root +is an error (C-rm3). -FL9. Focus is advanced before `next(node)` is called, so the focus chain is -walked while the node is still in the tree. After `next(node)` completes, the -node's scope is destroyed and it leaves the focus chain naturally (its props, -including `focused`, are gone). +FL9. Because the successor is computed before `next(node)`, the focus chain still +contains the removed node during the computation. The deferred `focus(successor)` +applies on a later cycle, by which point teardown is complete and the removed +node has left the chain naturally (its props, including `focused`, are gone). --- diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index 539f1de..4aecf5c 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -648,6 +648,43 @@ The helper is better than raw middleware because: - The `keyboard` API reference is encapsulated — the consumer does not need to know which API object to call `around()` on. +### 7.5 Eval Events + +E14. `DispatchEvent` is a value type Freedom defines for deferred, cross-node +evaluation: + + class DispatchEvent { + target: Node; + method: (...args: TArgs) => Operation; + args: TArgs; + callback?: (result: Result) => Operation; + } + +E15. A bundled extension — always installed by `useTree`, but using the same +dispatch-middleware mechanism as any other extension — recognizes a +`DispatchEvent` on the dispatch chain, runs +`const result = yield* target.eval(() => method(...args))`, then, if a +`callback` is present, runs `yield* callback(result)`. The handler returns +`{ ok: true, value: true }` on success or `{ ok: false, error }` on failure. +Events that are not `DispatchEvent` instances pass through to `next`. + +E16. Because an eval-event is drained in its own later dispatch cycle (E7), the +target's eval loop is idle when it runs, so the evaluation cannot deadlock +against a busy loop. This is the sanctioned mechanism for mutating a node from +within a dispatch. + +E17. This does not weaken §2.2 event-agnosticism: `dispatch` remains +`(event: unknown)`; `DispatchEvent` is one structural event type handled by a +bundled extension exactly as application vocabularies are handled (§7.3). + +E18. `dispatch(target, event)` is an operation usable in any node scope that +enqueues an eval-event via the tree's queue: +`tree.dispatch(new DispatchEvent({ ...event, target }))`. + +E19. `node.eval()` is now an internal primitive. It remains public and the +N-eval clauses (§5.3) hold, but extensions SHOULD prefer dispatching eval-events +for cross-node work; direct `node.eval()` is reserved for same-loop/self use. + --- ## 8. Tree and Notification From 087d137b7b8ead1cce8736de17d64eb6ef290de5 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 27 Jun 2026 12:56:58 +0300 Subject: [PATCH 14/47] =?UTF-8?q?=E2=9C=A8=20add=20DispatchEvent=20eval-ev?= =?UTF-8?q?ent=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/src/lib/dispatch.ts | 52 +++++++++++++++++++++ packages/freedom/src/lib/mod.ts | 7 ++- packages/freedom/src/lib/state.ts | 1 + packages/freedom/src/lib/tree.ts | 11 +++-- packages/freedom/test/freedom.test.ts | 67 +++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/freedom/src/lib/dispatch.ts b/packages/freedom/src/lib/dispatch.ts index d14286d..33866bf 100644 --- a/packages/freedom/src/lib/dispatch.ts +++ b/packages/freedom/src/lib/dispatch.ts @@ -9,6 +9,41 @@ export interface Dispatch { getNodeById(id: string): Operation; } +type Method = (...args: TArgs) => Operation; + +export class DispatchEvent { + target: Node; + method: Method; + args: TArgs; + callback?: (result: Result) => Operation; + + constructor(options: { + target: Node; + method: Method; + args: TArgs; + callback?: (result: Result) => Operation; + }) { + this.target = options.target; + this.method = options.method; + this.args = options.args; + this.callback = options.callback; + } +} + +// Enqueue an eval-event to run `event.method(...args)` in `target`'s scope on a +// later, idle cycle. Always requires a target. +export function* dispatch( + target: Node, + event: { + method: Method; + args: TArgs; + callback?: (result: Result) => Operation; + }, +): Operation { + const tree = yield* TreeContext.expect(); + tree.dispatch(new DispatchEvent({ ...event, target })); +} + export const DispatchApi: Api = createApi( "freedom:dispatch", { @@ -22,3 +57,20 @@ export const DispatchApi: Api = createApi( }, }, ); + +// Bundled core extension: handle DispatchEvent on the dispatch chain. +// Installed by useTree. +export function useDispatch(): Operation { + return DispatchApi.around({ + *dispatch([event], next) { + if (event instanceof DispatchEvent) { + const result = yield* event.target.eval(() => event.method(...event.args)); + if (event.callback) { + yield* event.callback(result); + } + return result.ok ? { ok: true, value: true } : result; + } + return yield* next(event); + }, + }); +} diff --git a/packages/freedom/src/lib/mod.ts b/packages/freedom/src/lib/mod.ts index fe18a2d..b0c0a02 100644 --- a/packages/freedom/src/lib/mod.ts +++ b/packages/freedom/src/lib/mod.ts @@ -24,7 +24,12 @@ export { useNode, } from "./freedom.ts"; -export { type Dispatch, DispatchApi } from "./dispatch.ts"; +export { + type Dispatch, + DispatchApi, + DispatchEvent, + dispatch, +} from "./dispatch.ts"; export { advance, diff --git a/packages/freedom/src/lib/state.ts b/packages/freedom/src/lib/state.ts index e1a1540..3dce6cd 100644 --- a/packages/freedom/src/lib/state.ts +++ b/packages/freedom/src/lib/state.ts @@ -8,6 +8,7 @@ export interface TreeState { nodes: Map; nextId(): string; markDirty(): void; + dispatch(event: unknown): void; } export const TreeContext = createContext("freedom:tree"); diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts index ee2a562..b94f5b8 100644 --- a/packages/freedom/src/lib/tree.ts +++ b/packages/freedom/src/lib/tree.ts @@ -9,7 +9,7 @@ import { import type { Component, Tree } from "./types.ts"; import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; import { TreeContext, type TreeState } from "./state.ts"; -import { DispatchApi } from "./dispatch.ts"; +import { DispatchApi, useDispatch } from "./dispatch.ts"; import { FreedomApi } from "./freedom.ts"; export function useTree(root: Component): Operation { @@ -29,6 +29,9 @@ export function useTree(root: Component): Operation { markDirty() { state.dirty = true; }, + dispatch(event) { + events.send(event); + }, }; yield* TreeContext.set(state); @@ -72,6 +75,8 @@ export function useTree(root: Component): Operation { }, }); + yield* useDispatch(); + yield* spawnEvalLoop(rootNode); // Subscribe to events, then spawn the event loop @@ -100,9 +105,7 @@ export function useTree(root: Component): Operation { yield* ready.operation; const tree: Tree = { - dispatch(event: unknown) { - events.send(event); - }, + dispatch: state.dispatch, root: rootNode, [Symbol.iterator]: output[Symbol.iterator], }; diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 96196ca..82ce9b7 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -3,6 +3,7 @@ import { run, sleep } from "effection"; import { append, DispatchApi, + DispatchEvent, FreedomApi, get, set, @@ -679,3 +680,69 @@ describe("useNode operation", () => { }); }); }); + +describe("eval events", () => { + it("DE1: runs method in the target node's scope on a later cycle", async () => { + await run(function* () { + let ran = false; + let tree = yield* useTree(function* () {}); + + let sub = yield* tree; + tree.dispatch(new DispatchEvent({ + target: tree.root, + method: function* () { + ran = true; + yield* set("touched", true); + }, + args: [], + })); + yield* sub.next(); + + expect(ran).toBe(true); + expect(tree.root.props["touched"]).toBe(true); + }); + }); + + it("DE2: fires callback with the result after the cycle", async () => { + await run(function* () { + let got: unknown = undefined; + let tree = yield* useTree(function* () {}); + + let sub = yield* tree; + tree.dispatch(new DispatchEvent({ + target: tree.root, + method: function* () { + yield* set("x", 1); + return 42; + }, + args: [], + callback: function* (r) { + got = r; + }, + })); + yield* sub.next(); + + expect(got).toEqual({ ok: true, value: 42 }); + }); + }); + + it("DE3: passes through non-DispatchEvent events", async () => { + await run(function* () { + let seen = false; + let tree = yield* useTree(function* () { + yield* DispatchApi.around({ + *dispatch([event], next) { + if (event === "ping") { + seen = true; + return { ok: true as const, value: true as const }; + } + return yield* next(event); + }, + }); + }); + tree.dispatch("ping"); + yield* sleep(0); + expect(seen).toBe(true); + }); + }); +}); From 400defc674c51b1b603f7167a30f1dffb2889827 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 27 Jun 2026 15:39:57 +0300 Subject: [PATCH 15/47] =?UTF-8?q?Revert=20"=E2=9C=A8=20add=20DispatchEvent?= =?UTF-8?q?=20eval-event=20extension"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 087d137b7b8ead1cce8736de17d64eb6ef290de5. --- packages/freedom/src/lib/dispatch.ts | 52 --------------------- packages/freedom/src/lib/mod.ts | 7 +-- packages/freedom/src/lib/state.ts | 1 - packages/freedom/src/lib/tree.ts | 11 ++--- packages/freedom/test/freedom.test.ts | 67 --------------------------- 5 files changed, 5 insertions(+), 133 deletions(-) diff --git a/packages/freedom/src/lib/dispatch.ts b/packages/freedom/src/lib/dispatch.ts index 33866bf..d14286d 100644 --- a/packages/freedom/src/lib/dispatch.ts +++ b/packages/freedom/src/lib/dispatch.ts @@ -9,41 +9,6 @@ export interface Dispatch { getNodeById(id: string): Operation; } -type Method = (...args: TArgs) => Operation; - -export class DispatchEvent { - target: Node; - method: Method; - args: TArgs; - callback?: (result: Result) => Operation; - - constructor(options: { - target: Node; - method: Method; - args: TArgs; - callback?: (result: Result) => Operation; - }) { - this.target = options.target; - this.method = options.method; - this.args = options.args; - this.callback = options.callback; - } -} - -// Enqueue an eval-event to run `event.method(...args)` in `target`'s scope on a -// later, idle cycle. Always requires a target. -export function* dispatch( - target: Node, - event: { - method: Method; - args: TArgs; - callback?: (result: Result) => Operation; - }, -): Operation { - const tree = yield* TreeContext.expect(); - tree.dispatch(new DispatchEvent({ ...event, target })); -} - export const DispatchApi: Api = createApi( "freedom:dispatch", { @@ -57,20 +22,3 @@ export const DispatchApi: Api = createApi( }, }, ); - -// Bundled core extension: handle DispatchEvent on the dispatch chain. -// Installed by useTree. -export function useDispatch(): Operation { - return DispatchApi.around({ - *dispatch([event], next) { - if (event instanceof DispatchEvent) { - const result = yield* event.target.eval(() => event.method(...event.args)); - if (event.callback) { - yield* event.callback(result); - } - return result.ok ? { ok: true, value: true } : result; - } - return yield* next(event); - }, - }); -} diff --git a/packages/freedom/src/lib/mod.ts b/packages/freedom/src/lib/mod.ts index b0c0a02..fe18a2d 100644 --- a/packages/freedom/src/lib/mod.ts +++ b/packages/freedom/src/lib/mod.ts @@ -24,12 +24,7 @@ export { useNode, } from "./freedom.ts"; -export { - type Dispatch, - DispatchApi, - DispatchEvent, - dispatch, -} from "./dispatch.ts"; +export { type Dispatch, DispatchApi } from "./dispatch.ts"; export { advance, diff --git a/packages/freedom/src/lib/state.ts b/packages/freedom/src/lib/state.ts index 3dce6cd..e1a1540 100644 --- a/packages/freedom/src/lib/state.ts +++ b/packages/freedom/src/lib/state.ts @@ -8,7 +8,6 @@ export interface TreeState { nodes: Map; nextId(): string; markDirty(): void; - dispatch(event: unknown): void; } export const TreeContext = createContext("freedom:tree"); diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts index b94f5b8..ee2a562 100644 --- a/packages/freedom/src/lib/tree.ts +++ b/packages/freedom/src/lib/tree.ts @@ -9,7 +9,7 @@ import { import type { Component, Tree } from "./types.ts"; import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; import { TreeContext, type TreeState } from "./state.ts"; -import { DispatchApi, useDispatch } from "./dispatch.ts"; +import { DispatchApi } from "./dispatch.ts"; import { FreedomApi } from "./freedom.ts"; export function useTree(root: Component): Operation { @@ -29,9 +29,6 @@ export function useTree(root: Component): Operation { markDirty() { state.dirty = true; }, - dispatch(event) { - events.send(event); - }, }; yield* TreeContext.set(state); @@ -75,8 +72,6 @@ export function useTree(root: Component): Operation { }, }); - yield* useDispatch(); - yield* spawnEvalLoop(rootNode); // Subscribe to events, then spawn the event loop @@ -105,7 +100,9 @@ export function useTree(root: Component): Operation { yield* ready.operation; const tree: Tree = { - dispatch: state.dispatch, + dispatch(event: unknown) { + events.send(event); + }, root: rootNode, [Symbol.iterator]: output[Symbol.iterator], }; diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 82ce9b7..96196ca 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -3,7 +3,6 @@ import { run, sleep } from "effection"; import { append, DispatchApi, - DispatchEvent, FreedomApi, get, set, @@ -680,69 +679,3 @@ describe("useNode operation", () => { }); }); }); - -describe("eval events", () => { - it("DE1: runs method in the target node's scope on a later cycle", async () => { - await run(function* () { - let ran = false; - let tree = yield* useTree(function* () {}); - - let sub = yield* tree; - tree.dispatch(new DispatchEvent({ - target: tree.root, - method: function* () { - ran = true; - yield* set("touched", true); - }, - args: [], - })); - yield* sub.next(); - - expect(ran).toBe(true); - expect(tree.root.props["touched"]).toBe(true); - }); - }); - - it("DE2: fires callback with the result after the cycle", async () => { - await run(function* () { - let got: unknown = undefined; - let tree = yield* useTree(function* () {}); - - let sub = yield* tree; - tree.dispatch(new DispatchEvent({ - target: tree.root, - method: function* () { - yield* set("x", 1); - return 42; - }, - args: [], - callback: function* (r) { - got = r; - }, - })); - yield* sub.next(); - - expect(got).toEqual({ ok: true, value: 42 }); - }); - }); - - it("DE3: passes through non-DispatchEvent events", async () => { - await run(function* () { - let seen = false; - let tree = yield* useTree(function* () { - yield* DispatchApi.around({ - *dispatch([event], next) { - if (event === "ping") { - seen = true; - return { ok: true as const, value: true as const }; - } - return yield* next(event); - }, - }); - }); - tree.dispatch("ping"); - yield* sleep(0); - expect(seen).toBe(true); - }); - }); -}); From b108c4d6227f7d5c7950b53263f96f904a35fb77 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 27 Jun 2026 15:39:57 +0300 Subject: [PATCH 16/47] =?UTF-8?q?Revert=20"=F0=9F=93=9D=20spec:=20event-dr?= =?UTF-8?q?iven=20focus=20via=20eval-event=20extension"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0758092febafa422116437242178cef0cc78b21d. --- packages/freedom/specs/freedom-focus-spec.md | 49 +++++++------------- packages/freedom/specs/freedom-spec.md | 37 --------------- 2 files changed, 16 insertions(+), 70 deletions(-) diff --git a/packages/freedom/specs/freedom-focus-spec.md b/packages/freedom/specs/freedom-focus-spec.md index f947851..fd5831b 100644 --- a/packages/freedom/specs/freedom-focus-spec.md +++ b/packages/freedom/specs/freedom-focus-spec.md @@ -96,31 +96,18 @@ from becoming focusable. **advance** -F4. `advance()` *requests* a move to the next focusable node in the focus chain -(§5) by enqueuing an eval-event (Freedom spec §7.5). The move is applied when -that event is drained, on a subsequent dispatch cycle; when the eval-event -completes successfully (observable via its `callback`), focus has moved. +F4. `advance()` moves focus to the next focusable node in the focus chain (§5). F5. If the currently focused node is the last in the focus chain, `advance()` wraps to the first focusable node. -F6. Focus movement is performed by the eval-event handler, which runs the -`FocusApi` operation in the target node's scope, setting the old node's -`focused` property to `false` and the new node's to `true`. Because the handler -runs in its own idle cycle, these writes use the target nodes' free eval loops -without deadlock. +F6. Focus movement sets the old node's `focused` property to `false` and the new +node's `focused` property to `true`, using `node.eval()` to run property +operations in each node's scope (§6.2). F7. If the focus chain contains only one focusable node (including root), `advance()` is a no-op. -F7a. `advance`, `retreat`, and `focus` are **deferred**: each resolves once its -eval-event is *enqueued*, not once focus has moved. The move itself is observable -via the eval-event `callback` — a `callback` receiving `{ ok: true }` means focus -has moved; an `{ ok: false }` means it did not (e.g. a non-focusable target). -`current()` and `focusable()` remain synchronous. Awaiting completion from -*inside* the triggering dispatch is impossible by construction (it would re-create -the deadlock the deferral exists to avoid), so the public ops resolve on enqueue. - **retreat** F8. `retreat()` moves focus to the previous focusable node in the focus chain @@ -240,33 +227,29 @@ occur within the same dispatch cycle. ### 6.3 Focused node removal -FL7. `useFocus()` installs middleware on the `remove` context API operation -that, when the node being removed is focused, computes its successor in the focus -chain **synchronously (before teardown)** and enqueues `focus(successor)`, then -calls `next(node)`: +FL7. `useFocus()` installs middleware on the `remove` context API operation that +advances focus before a focused node is destroyed: ```ts yield* FreedomApi.around({ *remove([node], next) { - if (node.props.focused === true) { - let successor = successorOf(node); // sync chain walk, excludes node - if (successor && successor !== node) { - yield* focus(successor); // deferred eval-event - } + let focused = yield* node.eval(() => get("focused")); + if (focused === true) { + yield* FocusApi.operations.advance(); } yield* next(node); }, }); ``` -FL8. If the only focusable node is root, there is no successor and no focus event -is enqueued. This case should not arise in practice because `remove` on the root -is an error (C-rm3). +FL8. If the removed node is the only focusable node (i.e., it is the root), +`advance()` is a no-op (F7) and focus remains on root. This case should not +arise in practice because `remove` on the root is an error (C-rm3). -FL9. Because the successor is computed before `next(node)`, the focus chain still -contains the removed node during the computation. The deferred `focus(successor)` -applies on a later cycle, by which point teardown is complete and the removed -node has left the chain naturally (its props, including `focused`, are gone). +FL9. Focus is advanced before `next(node)` is called, so the focus chain is +walked while the node is still in the tree. After `next(node)` completes, the +node's scope is destroyed and it leaves the focus chain naturally (its props, +including `focused`, are gone). --- diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index 4aecf5c..539f1de 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -648,43 +648,6 @@ The helper is better than raw middleware because: - The `keyboard` API reference is encapsulated — the consumer does not need to know which API object to call `around()` on. -### 7.5 Eval Events - -E14. `DispatchEvent` is a value type Freedom defines for deferred, cross-node -evaluation: - - class DispatchEvent { - target: Node; - method: (...args: TArgs) => Operation; - args: TArgs; - callback?: (result: Result) => Operation; - } - -E15. A bundled extension — always installed by `useTree`, but using the same -dispatch-middleware mechanism as any other extension — recognizes a -`DispatchEvent` on the dispatch chain, runs -`const result = yield* target.eval(() => method(...args))`, then, if a -`callback` is present, runs `yield* callback(result)`. The handler returns -`{ ok: true, value: true }` on success or `{ ok: false, error }` on failure. -Events that are not `DispatchEvent` instances pass through to `next`. - -E16. Because an eval-event is drained in its own later dispatch cycle (E7), the -target's eval loop is idle when it runs, so the evaluation cannot deadlock -against a busy loop. This is the sanctioned mechanism for mutating a node from -within a dispatch. - -E17. This does not weaken §2.2 event-agnosticism: `dispatch` remains -`(event: unknown)`; `DispatchEvent` is one structural event type handled by a -bundled extension exactly as application vocabularies are handled (§7.3). - -E18. `dispatch(target, event)` is an operation usable in any node scope that -enqueues an eval-event via the tree's queue: -`tree.dispatch(new DispatchEvent({ ...event, target }))`. - -E19. `node.eval()` is now an internal primitive. It remains public and the -N-eval clauses (§5.3) hold, but extensions SHOULD prefer dispatching eval-events -for cross-node work; direct `node.eval()` is reserved for same-loop/self use. - --- ## 8. Tree and Notification From ffcb997232f714283fdc4f9a64b8ed060cd60740 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 27 Jun 2026 16:10:25 +0300 Subject: [PATCH 17/47] =?UTF-8?q?=F0=9F=93=9D=20spec:=20node=20owns=20a=20?= =?UTF-8?q?scope,=20eval=20runs=20inline=20(non-serial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/specs/freedom-spec.md | 40 ++++++++++++++------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index 539f1de..bd58391 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -96,10 +96,10 @@ framework-assigned unique `id`, an optional name, a property bag, an ordered list of children, and a parent (except the root). A node's in-memory identity is its object reference; its externalizable identity is its `id`. The Node's data fields are read-only — all property mutations go through the context API. Each -node maintains an eval loop that accepts operations via `node.eval()`, enabling -scoped execution. `node.remove()` delegates to the `remove` context API -operation, which halts the node's scope and tears down all descendants. -Middleware on `remove` participates in teardown. +node owns an Effection scope; `node.eval()` runs operations inline in that scope. +`node.remove()` delegates to the `remove` context API operation, which halts the +node's scope and tears down all descendants. Middleware on `remove` participates +in teardown. **Component.** A generator function of type `() => Operation` that runs within a node's Effection scope for the node's entire lifetime. Components @@ -240,11 +240,10 @@ uniqueness, or reconciliation. ### 5.3 Scoped Evaluation -Each node maintains an **eval loop** — a spawned child task within the node's -scope that accepts and executes operations submitted via `node.eval()`. The eval -loop uses a channel- based "fire and await" pattern: the caller submits an -operation and waits for its result; the eval loop executes the operation in the -node's scope and resolves the result. +Each node owns an Effection **scope** (captured when its component task starts). +`node.eval(op)` runs `op` **inline** in the caller's routine with the routine's +scope temporarily repointed at the node's scope, then restored. There is no eval +loop and no channel. N-eval1. `node.eval(op)` runs `op` within the node's Effection scope and returns `Result`. If `op` completes successfully, the result is @@ -253,22 +252,25 @@ N-eval1. `node.eval(op)` runs `op` within the node's Effection scope and returns N-eval2. Operations yielded inside `op` see the node's scope context — including all middleware installed in the node's scope and its ancestors. -N-eval3. The eval loop executes operations sequentially. If multiple callers -submit operations concurrently, they are processed in order. +N-eval3. `eval` is immediate — it runs `op` synchronously inline — and +non-serial: it does not queue. Re-entrant and concurrent `eval` calls nest, each +restoring the previous scope when it completes. The only serialization point in +the system is the tree's dispatch queue (§7). -N-eval4. The eval loop is spawned as a child of the node's scope. It runs -alongside the component and shares the same parent scope, so middleware -installed by the component is visible to operations executed via `eval`. +N-eval4. Because `op` runs with the node's scope as the current scope, middleware +installed by the node's component is visible to operations executed via `eval`. ### 5.4 Lifecycle -N7. A node is created by `append()`, which spawns a new Effection child scope -within the parent node's scope and runs the component within it. +N7. A node is created by `append()`, which spawns a task within the parent node's +scope and runs the component within it. The task captures its scope +(`useScope()`) as the node's scope, so the node's scope inherits the parent's +contexts. N8. A node is destroyed by `node.remove()`, which halts the node's spawned task. -This triggers structured teardown of the component, the eval loop, all -middleware installed in the scope, and all descendant nodes. `remove()` does not -return until teardown is complete. +This triggers structured teardown of the component, all middleware installed in +the scope, and all descendant nodes. `remove()` does not return until teardown is +complete. N9. When a node is destroyed, its middleware disappears automatically because the Effection scope is destroyed. From dd8a9237b5522516c461ba3472cc945b9c80a9a1 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 27 Jun 2026 16:58:25 +0300 Subject: [PATCH 18/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20run=20eval=20inline?= =?UTF-8?q?=20via=20per-node=20scope;=20remove=20the=20eval=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each node captures its task scope (useScope); eval repoints the current routine's scope at the node's scope, runs the op inline, and restores — immediate, non-serial, re-entrant by nesting. Removes the channel/eval-loop, CallEval, box, and EvalOwner. Focus stays synchronous and no longer deadlocks on cross-node setFocused during dispatch. GA3 now waits a turn (sleep 0) for concurrent component init, like its siblings — inline eval no longer grants the incidental scheduler turn the channel did. --- packages/freedom/src/lib/freedom.ts | 5 +- packages/freedom/src/lib/node.ts | 88 ++++++++------------------- packages/freedom/src/lib/tree.ts | 6 +- packages/freedom/test/freedom.test.ts | 1 + 4 files changed, 32 insertions(+), 68 deletions(-) diff --git a/packages/freedom/src/lib/freedom.ts b/packages/freedom/src/lib/freedom.ts index db3a8a5..858fd46 100644 --- a/packages/freedom/src/lib/freedom.ts +++ b/packages/freedom/src/lib/freedom.ts @@ -5,6 +5,7 @@ import { type Operation, spawn, suspend, + useScope, withResolvers, } from "effection"; import { createApi } from "effection/experimental"; @@ -14,7 +15,7 @@ import { type JsonValue, type Node, } from "./types.ts"; -import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; +import { NodeContext, NodeImpl } from "./node.ts"; import { TreeContext } from "./state.ts"; import { validateJsonValue } from "./validate.ts"; @@ -78,7 +79,7 @@ export const FreedomApi: Api = createApi("freedom:node", { parent._children.add(child); tree.nodes.set(child.id, child); yield* NodeContext.set(child); - yield* spawnEvalLoop(child); + child.scope = yield* useScope(); ready.resolve(); try { yield* component(); diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index eacbb31..9f4ba44 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -1,17 +1,13 @@ // oxlint-disable bombshell-dev/no-generic-error // oxlint-disable max-params import { - type Channel, type Context, - createChannel, createContext, Err, Ok, type Operation, type Result, - spawn, - type Stream, - withResolvers, + type Scope, } from "effection"; import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; @@ -38,29 +34,12 @@ class NodeDataImpl implements NodeData { } } -interface CallEval { - operation: () => Operation; - resolve: (result: Result) => void; -} - -function box(op: () => Operation): Operation> { - return { - *[Symbol.iterator]() { - try { - return Ok(yield* op()); - } catch (error) { - return Err(error as Error); - } - }, - }; -} - export class NodeImpl implements Node { _props: Record = {}; _children: Set = new Set(); _sortFn: ((a: Node, b: Node) => number) | undefined = undefined; - _channel: Channel = createChannel(); data: NodeData = new NodeDataImpl(); + scope!: Scope; constructor( readonly id: string, @@ -95,18 +74,30 @@ export class NodeImpl implements Node { } *eval(op: () => Operation): Operation> { - // Re-entrant call from inside this node's own eval loop: running through - // the channel would deadlock (the loop is busy awaiting us), so run inline. - const owner = yield* EvalOwner.get(); - if (owner === this) { - return yield* box(op); + // Run `op` inline with the current routine's scope temporarily repointed at + // this node's scope, so the op sees this node's contexts/middleware. + const restore = (yield { + description: "freedom: enter node scope", + enter: ( + resolve: (result: Result<() => void>) => void, + routine: { scope: Scope }, + ) => { + const original = routine.scope; + routine.scope = this.scope; + resolve(Ok(() => { + routine.scope = original; + })); + return (resolveExit: (result: Result) => void) => + resolveExit(Ok()); + }, + }) as () => void; + try { + return Ok(yield* op()); + } catch (error) { + return Err(error as Error); + } finally { + restore(); } - const resolver = withResolvers>(); - yield* this._channel.send({ - resolve: resolver.resolve as (result: Result) => void, - operation: op as () => Operation, - }); - return yield* resolver.operation; } remove(): Operation { @@ -114,35 +105,6 @@ export class NodeImpl implements Node { } } -export function* spawnEvalLoop(node: NodeImpl): Operation { - const ready = withResolvers(); - - yield* spawn(function* () { - const sub = yield* node._channel as Stream; - // Mark this task's scope as the owner of node's eval loop, so a re-entrant - // node.eval() running within it short-circuits inline instead of deadlocking. - yield* EvalOwner.set(node); - ready.resolve(); - - while (true) { - const next = yield* sub.next(); - if (next.done) { - break; - } - const call = next.value; - const result = yield* box(call.operation); - call.resolve(result); - } - }); - - yield* ready.operation; -} - export const NodeContext: Context = createContext( "freedom:current-node", ); - -// Set within a node's eval loop to identify re-entrant self-eval calls. -const EvalOwner: Context = createContext( - "freedom:eval-owner", -); diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts index ee2a562..e60ac30 100644 --- a/packages/freedom/src/lib/tree.ts +++ b/packages/freedom/src/lib/tree.ts @@ -4,10 +4,11 @@ import { resource, spawn, suspend, + useScope, withResolvers, } from "effection"; import type { Component, Tree } from "./types.ts"; -import { NodeContext, NodeImpl, spawnEvalLoop } from "./node.ts"; +import { NodeContext, NodeImpl } from "./node.ts"; import { TreeContext, type TreeState } from "./state.ts"; import { DispatchApi } from "./dispatch.ts"; import { FreedomApi } from "./freedom.ts"; @@ -41,6 +42,7 @@ export function useTree(root: Component): Operation { // Spawn root node scope yield* spawn(function* () { + rootNode.scope = yield* useScope(); yield* NodeContext.set(rootNode); // Mark dirty after every mutation @@ -72,8 +74,6 @@ export function useTree(root: Component): Operation { }, }); - yield* spawnEvalLoop(rootNode); - // Subscribe to events, then spawn the event loop const sub = yield* events; yield* spawn(function* () { diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 96196ca..c196532 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -537,6 +537,7 @@ describe("get operation", () => { }, }); }); + yield* sleep(0); let result = yield* tree.root.eval(function* () { return yield* get("k"); }); From 6991b54e07c58736fe6b3c51b0de4c98c38f7c59 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sun, 28 Jun 2026 08:45:51 +0300 Subject: [PATCH 19/47] =?UTF-8?q?=F0=9F=93=9D=20spec:=20synchronous=20free?= =?UTF-8?q?dom:node=20API,=20imperative=20Root/Node,=20drop=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/specs/freedom-spec.md | 392 ++++++++++--------------- 1 file changed, 153 insertions(+), 239 deletions(-) diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index bd58391..0387d00 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -45,14 +45,14 @@ into their own typed APIs. This allows Freedom to serve DOM applications, terminal applications, game engines, or any other event source without modification. -### 2.3 Operations over methods +### 2.3 Synchronous mutations, async events -Node mutations (`set`, `update`) and structural operations (`append`, `remove`, -`sort`) are Effection context API operations, not methods on the Node object. -This makes them interceptable by middleware — a parent scope can wrap `set` to -validate, transform, log, or reject property changes in its subtree. The -component signature is `() => Operation`; everything is accessed through -the ambient scope. +Node mutations (`set`, `update`, `unset`, `createChild`, `remove`, `sort`) are +**synchronous methods** on the `Node` object. They remain interceptable — +synchronous interceptors installed via `node.scope.around` can validate, +transform, log, or reject changes in a subtree (§6). Only event **dispatch** +(§7) is operation-based; that is where asynchronous work lives. This keeps the +tree drivable imperatively (e.g., by a React/Svelte reconciler). ### 2.4 Orthogonal concerns @@ -72,41 +72,31 @@ immediate-mode rendering patterns (e.g., clayterm rebuilds `Op[]` every frame). The JSON-like property constraint does not foreclose on richer change records in the future. -### 2.6 Read-only Node surface +### 2.6 Read-only data, synchronous mutators The `Node` object's data fields — `id`, `name`, `props`, `children`, `parent` — -are for reading only. All property and structural mutations go through the -`freedom:node` context API, which makes every mutation interceptable by -middleware. `Node` also exposes `eval()` for scoped operation execution and -`remove()` for lifecycle management. `remove()` is both a convenience method on -`Node` and a context API operation — the method delegates to the operation, -ensuring middleware always participates in teardown. +are for reading only. All mutations go through synchronous `Node` methods +(`set`, `update`, `unset`, `createChild`, `sort`, `remove`), each interceptable +by synchronous middleware installed on the node's scope (§6). `node.destroy()` +disposes the node's scope for teardown. --- ## 3. Terminology -**Tree.** The root resource that owns all component nodes. Externally it is an -event sink (`dispatch`) and a change source (`Stream`). Internally it is -an Effection scope that contains the root node, the event loop, and the -notification mechanism. +**Root.** The synchronous entry created by `createRoot()`. Externally it exposes +the root `node`, an event sink (`dispatch`), a change source (`Stream`), +and `destroy()`. The root node owns the top scope and runs the event loop. Root +replaces the former `Tree`. **Node.** A long-lived Effection resource within the tree. Each node has a framework-assigned unique `id`, an optional name, a property bag, an ordered list of children, and a parent (except the root). A node's in-memory identity is its object reference; its externalizable identity is its `id`. The Node's data -fields are read-only — all property mutations go through the context API. Each -node owns an Effection scope; `node.eval()` runs operations inline in that scope. -`node.remove()` delegates to the `remove` context API operation, which halts the -node's scope and tears down all descendants. Middleware on `remove` participates -in teardown. - -**Component.** A generator function of type `() => Operation` that runs -within a node's Effection scope for the node's entire lifetime. Components -access node operations and event middleware through the ambient scope. A -component either does its initialization work and returns, or enters an infinite -loop that reacts to changes. In either case, the node remains alive — the node's -scope outlives the component generator. +fields are read-only — all mutations go through synchronous methods (§6). Each +node owns an Effection scope created in its constructor. `node.remove()` detaches +the node and tears down its scope (and descendants); synchronous interceptors on +the node's scope participate. **Property bag.** A `Record` on each node. Properties are the node's state AND its renderable description — they are the same thing. @@ -116,9 +106,9 @@ Properties are namespaced by convention (e.g., `"clay"`, `"aria"`). `string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }`. No `undefined`. Properties are removed with `unset()`. -**Dispatch.** The synchronous entry point for events. `tree.dispatch(event)` -pushes an event into the tree's internal Signal. The event is then processed -operationally through middleware. +**Dispatch.** The synchronous entry point for events. `root.dispatch(event)` +pushes an event into the root's internal Signal. The event is then processed +operationally (asynchronously) through dispatch middleware (§7). **Middleware.** An Effection `createApi` middleware function that intercepts an operation. In Freedom, middleware is used for both event handling @@ -203,17 +193,23 @@ interface Node { readonly children: Iterable; readonly parent: Node | undefined; readonly data: NodeData; - eval(op: () => Operation): Operation>; - remove(): Operation; + get(key: string): JsonValue | undefined; + set(key: string, value: JsonValue): void; + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; + unset(key: string): void; + createChild(name?: string): Node; + sort(fn?: (a: Node, b: Node) => number): void; + remove(): void; + destroy(): Promise; } ``` -The Node's data fields are read-only. All property and structural mutations go -through the `freedom:node` context API (§6). `eval` is a method on Node because -it requires a specific node reference. `remove` is both a method and a context -API operation — the method delegates to the operation (§6.2), ensuring -middleware participates in teardown. `data` provides typed, symbol-keyed storage -for private, non-serializable per-node state (§5.7). +The Node's data fields are read-only; all mutations go through the synchronous +methods (§6). Each node creates its own Effection scope in its constructor +(§5.3), inheriting the parent's contexts; mutations and their interceptors +resolve against that scope. `remove()` detaches the node and tears it down via +`destroy()`. `data` provides typed, symbol-keyed storage for private, +non-serializable per-node state (§5.7). ### 5.2 Identity @@ -238,51 +234,38 @@ until explicitly removed or its parent scope exits. N6. The `name` field is informational. It does NOT participate in identity, uniqueness, or reconciliation. -### 5.3 Scoped Evaluation +### 5.3 Node Scope -Each node owns an Effection **scope** (captured when its component task starts). -`node.eval(op)` runs `op` **inline** in the caller's routine with the routine's -scope temporarily repointed at the node's scope, then restored. There is no eval -loop and no channel. +Each node owns an Effection **scope**, created in its constructor as a child of +its parent's scope (`createScope(parent?.scope)`), so it inherits the parent's +contexts. Mutations (§6) and their synchronous interceptors resolve against this +scope. There is no eval loop, no channel, and no `eval` method. -N-eval1. `node.eval(op)` runs `op` within the node's Effection scope and returns -`Result`. If `op` completes successfully, the result is -`{ ok: true, value: T }`. If `op` throws, the result is `{ ok: false, error }`. +N-scope1. A node's scope is created with the node and torn down by +`node.destroy()` (disposing the scope halts all descendant node scopes and +resources). -N-eval2. Operations yielded inside `op` see the node's scope context — including -all middleware installed in the node's scope and its ancestors. - -N-eval3. `eval` is immediate — it runs `op` synchronously inline — and -non-serial: it does not queue. Re-entrant and concurrent `eval` calls nest, each -restoring the previous scope when it completes. The only serialization point in -the system is the tree's dispatch queue (§7). - -N-eval4. Because `op` runs with the node's scope as the current scope, middleware -installed by the node's component is visible to operations executed via `eval`. +N-scope2. Synchronous interceptors installed via `node.scope.around(...)` are +visible to mutations on this node and its descendants, via context inheritance. ### 5.4 Lifecycle -N7. A node is created by `append()`, which spawns a task within the parent node's -scope and runs the component within it. The task captures its scope -(`useScope()`) as the node's scope, so the node's scope inherits the parent's -contexts. +N7. A node is created by `parent.createChild(name?)`: the constructor creates the +child's scope (a child of the parent's), the child is attached to the parent's +children, and it is returned synchronously. -N8. A node is destroyed by `node.remove()`, which halts the node's spawned task. -This triggers structured teardown of the component, all middleware installed in -the scope, and all descendant nodes. `remove()` does not return until teardown is -complete. +N8. A node is destroyed by `node.remove()` (detach + teardown) or directly by +`node.destroy()`, which disposes the node's scope — halting all descendant node +scopes and resources. -N9. When a node is destroyed, its middleware disappears automatically because -the Effection scope is destroyed. +N9. When a node is destroyed, its interceptors disappear automatically because +its Effection scope is destroyed. -N10. When a parent node is destroyed, all child nodes are destroyed in reverse -creation order (LIFO), per Effection's structured concurrency guarantees. +N10. When a parent node is destroyed, all descendant nodes are destroyed per +Effection's structured concurrency guarantees. -N11. `node.remove()` delegates to the `remove` context API operation (§6.2) via -`yield* node.eval(() => remove(node))`. The `remove` operation runs inside the -node's scope, and all middleware on `remove` participates in the teardown (outer -to inner). The innermost implementation halts the scope from within. The method -remains on `Node` for ergonomic use by parents: `yield* child.remove()`. +N11. `node.remove()` detaches the node from its parent, marks the tree dirty +(§8), and calls `node.destroy()` to dispose its scope. N12. Calling `remove()` on the root node is an error (C-rm3). @@ -379,8 +362,14 @@ interface Node { readonly children: Iterable; readonly parent: Node | undefined; readonly data: NodeData; - eval(op: () => Operation): Operation>; - remove(): Operation; + get(key: string): JsonValue | undefined; + set(key: string, value: JsonValue): void; + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; + unset(key: string): void; + createChild(name?: string): Node; + sort(fn?: (a: Node, b: Node) => number): void; + remove(): void; + destroy(): Promise; } ``` @@ -390,130 +379,111 @@ interface Node { ### 6.1 API Definition -The node context API is an Effection API created with `createApi`: +The `freedom:node` API is **synchronous**. Its operations are methods on `Node` +(§5); they are not Effection operations. Interceptors are synchronous functions +installed per scope via `scope.around`: ``` -createApi("freedom:node", { - *useNode(): Operation, - *get(key: string): Operation, - *set(key: string, value: JsonValue): Operation, - *update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): Operation, - *unset(key: string): Operation, - *append(name: string, component: Component): Operation, - *remove(node: Node): Operation, - *sort(fn: ((a: Node, b: Node) => number) | undefined): Operation, -}) +node.get(key): JsonValue | undefined +node.set(key, value): void +node.update(key, fn: (prev: JsonValue | undefined) => JsonValue): void +node.unset(key): void +node.createChild(name?): Node +node.sort(fn?: (a: Node, b: Node) => number): void +node.remove(): void +node.destroy(): Promise ``` -Note: `node.remove()` is a convenience method that delegates to the `remove` -context API operation via `yield* node.eval(() => remove(node))`. This ensures -all middleware on `remove` participates in teardown. - -### 6.2 Operations - -**useNode** +C-api1. A mutation resolves its composed interceptor chain off the node's scope +and invokes it **synchronously**, passing the `node`. -C-node1. `useNode()` returns the current node from the ambient scope as a -`Node`. +C-api2. There is no `useNode` and no `eval` — components are removed (§9); nodes +are referenced directly. -C-node2. `useNode()` MUST be called within a node's scope (inside a component -body, middleware, or an `eval` call). If called outside a node scope, it MUST -raise an error. +### 6.2 Operations -C-node3. `useNode` is a `createApi` operation and can be intercepted by -middleware. A parent MAY install middleware on `useNode` to substitute a -different node reference (e.g., for virtualization or proxying). +All operations are synchronous methods on `Node`. **get** -C-get1. `get(key)` returns the value of `key` in the current node's property -bag, or `undefined` if the key does not exist. - -C-get2. `get` is a `createApi` operation and can be intercepted by middleware. A -parent MAY install middleware on `get` to transform, log, or virtualize property -reads. +C-get1. `get(key)` returns the value of `key` in the node's property bag, or +`undefined` if the key does not exist. **set** -C1. `set(key, value)` stores `value` under `key` in the current node's property -bag. +C1. `set(key, value)` stores `value` under `key` in the node's property bag. -C2. `value` MUST be a valid JsonValue (§4). If not, the operation MUST raise an -error. +C2. `value` MUST be a valid JsonValue (§4). If not, `set` throws. C3. If `key` already exists, the previous value is replaced. -C4. `set` triggers a tree notification (§8). +C4. `set` marks the tree dirty (§8). **update** C5. `update(key, fn)` calls `fn` with the current value of `key` (or `undefined` if the key does not exist) and stores the result. -C6. The return value of `fn` MUST be a valid JsonValue (§4). If not, the -operation MUST raise an error. +C6. The return value of `fn` MUST be a valid JsonValue (§4). If not, `update` +throws. -C7. `update` triggers a tree notification (§8). +C7. `update` marks the tree dirty (§8). **unset** -C8. `unset(key)` removes `key` from the current node's property bag. +C8. `unset(key)` removes `key` from the node's property bag. -C9. If `key` does not exist, `unset` is a no-op. It MUST NOT raise an error. +C9. If `key` does not exist, `unset` is a no-op. It MUST NOT throw. -C10. `unset` triggers a tree notification (§8) only if the key existed. +C10. `unset` marks the tree dirty (§8) only if the key existed. -**append** +**createChild** -C11. `append(name, component)` creates a new child node with the given name, -spawns an Effection child scope, and runs the component within it. +C11. `createChild(name?)` creates a new child node with the given name (default +`""`) and a unique `id`. The child's constructor creates its scope as a child of +this node's scope (inheriting contexts). -C12. `append` returns the newly created Node. +C12. `createChild` attaches the child to this node's children and returns it +**synchronously**. -C13. `append` triggers a tree notification (§8). - -C14. The child node's scope is a child of the current node's scope. Middleware -installed in the child's scope inherits from the parent's scope per Effection's -context prototype chain. +C13. `createChild` marks the tree dirty (§8). **sort** -C19. `sort(fn)` installs a sort function on the current node. When `fn` is -defined, iterating this node's `children` applies `fn` to determine ordering. -When `fn` is `undefined`, the sort function is cleared and ordering reverts to -insertion order. +C19. `sort(fn)` installs a sort function on the node. When `fn` is defined, +iterating this node's `children` applies `fn` to determine ordering. When `fn` is +`undefined`, the sort function is cleared and ordering reverts to insertion +order. -C20. `sort` triggers a tree notification (§8) (N19). +C20. `sort` marks the tree dirty (§8) (N19). **remove** -C-rm1. `remove(node)` removes the given node. The innermost (default) -implementation halts the node's scope from within, triggering structured -teardown of the component, the eval loop, all middleware installed in the scope, -and all descendant nodes. The halt mechanism lives inside the scope (e.g., a -resolver the suspended task awaits) — the node does not hold a reference to its -task. +C-rm1. `remove()` detaches the node from its parent and tears down the node's +scope (and all descendant nodes) via `destroy()`. Detach is synchronous; +`destroy()` is the async scope dispose `remove` builds on. + +C-rm3. `remove()` on the root node is an error. -C-rm2. `remove(node)` is a `createApi` operation and can be intercepted by -middleware. Extensions (e.g., focus) MAY install middleware on `remove` to -perform cleanup before the node is destroyed. +C-rm4. `remove` marks the tree dirty (§8). -C-rm3. `remove(node)` on the root node is an error. +**destroy** -C-rm4. `remove` triggers a tree notification (§8). +C-d1. `destroy()` disposes the node's scope — halting all descendant node scopes +and resources — and returns a `Promise`. ### 6.3 Middleware Interception -C21. Because `get`, `set`, `update`, `unset`, `append`, `remove`, and `sort` are -`createApi` operations, they can be intercepted by middleware installed in -ancestor scopes. +C21. Interceptors are installed with `node.scope.around(...)` as **synchronous +functions** of shape `(node, args, next) => result`, registered per scope and +inherited by descendant scopes. -C22. A parent component MAY install middleware on `freedom:node` to validate, -transform, log, or reject property changes in its subtree. +C22. Interceptors run synchronously. They receive the `node` and MAY read any +context via the synchronous `node.scope.get/expect(...)`. -C23. Middleware on `set` receives the `[key, value]` tuple and a `next` -function. It MAY modify the key or value before calling `next`, or it MAY skip -`next` to reject the mutation. +C23. An interceptor MAY transform args, skip `next` to reject a mutation, or act +after `next`. An interceptor MUST NOT perform asynchronous work (`await` / +`yield*`); async concerns belong on the dispatch/event side (§7). --- @@ -652,40 +622,39 @@ The helper is better than raw middleware because: --- -## 8. Tree and Notification +## 8. Root and Notification -### 8.1 Tree Interface +### 8.1 Root Interface ``` -interface Tree extends Stream { +interface Root extends Stream { + node: Node; dispatch(event: unknown): void; - root: Node; + destroy(): Promise; } ``` -T1. The tree is an Effection resource. Creating it starts a root Effection scope -that owns all nodes and the event loop. +T1. The root node owns the top Effection scope, which owns all nodes and runs the +event loop. -T2. `tree.root` is the root node. It is created when the tree is created and -exists for the tree's lifetime. +T2. `root.node` is the root node, created by `createRoot()` and existing for the +root's lifetime. -T3. `tree.dispatch(event)` is the synchronous event entry point (§7.2). +T3. `root.dispatch(event)` is the synchronous event entry point (§7.2). -T4. The tree is a `Stream`. Subscribing to the stream yields -notifications when the tree changes. +T4. `Root` is a `Stream`. Subscribing yields notifications when the +tree changes. ### 8.2 Creation -T5. `createTree(root: Component): Operation` creates a tree. It: 1. -Creates the root Effection scope. 2. Creates the event Signal. 3. Creates the -root node. 4. Runs the root component within the root node's scope. 5. Spawns -the event loop, which dispatches events through the root node via `root.eval()`. -6. Returns the Tree. +T5. `createRoot(): Root` synchronously creates a root. It: 1. Creates the tree +state and Signals. 2. Constructs the root node (whose constructor creates the top +scope). 3. Sets `TreeContext` and installs the `markDirty` interceptor via +`scope.around`. 4. Starts the event-loop task on the root node's scope. 5. +Returns the `Root`. -T6. `createTree` returns once the root node's eval loop is ready to accept -operations. The root component runs concurrently within the root node's scope — -it MAY still be executing when `createTree` returns. The tree is usable as soon -as `eval` is operational. +T6. `createRoot()` is synchronous: the root and its node are fully usable when it +returns. There is no component to run. ### 8.3 Notification @@ -713,76 +682,21 @@ state. ### 8.4 Lifecycle -T13. Destroying the tree (exiting its Effection scope) destroys all nodes, stops -the event loop, and closes the output stream. +T13. `root.destroy()` disposes the root node's scope, which destroys all nodes, +stops the event loop, and closes the output stream; it returns a `Promise`. -T14. Events dispatched after the tree is destroyed are silently dropped. This is -a natural consequence of structured concurrency — the event Signal is inert once -the tree's scope exits. Implementations do NOT need an explicit alive check. +T14. Events dispatched after the root is destroyed are silently dropped — the +event Signal is inert once the scope exits. Implementations do NOT need an +explicit alive check. --- -## 9. Component - -### 9.1 Signature - -``` -type Component = () => Operation; -``` - -P1. A component is a generator function that takes no arguments and returns -`Operation`. - -P2. A component runs within a node's Effection scope. It accesses node -operations and event middleware through the ambient scope via `createApi` -operations. - -P3. A component's lifetime is the node's lifetime. When the node is removed, the -component's operation is cancelled per Effection's structured concurrency. - -### 9.2 Execution Model - -P4. The node's scope outlives the component generator. When the generator -returns, the node remains alive — the eval loop (§5.3) keeps the scope active, -properties persist, and middleware remains active. - -P5. A component takes one of two forms: - - **Initialization-only:** The component sets properties, - installs middleware, appends children, and returns. The - node lives on via its eval loop. - - ```ts - function* header(): Operation { - yield* set("clay", { type: "element", bg: 0xFF0000 }); - yield* set("label", "Hello"); - yield* append("subtitle", subtitle); - } - ``` - - **Reactive:** The component spawns a long-lived task - that reacts to changes over time, then returns. - - ```ts - function* clock(): Operation { - yield* spawn(function* () { - let i = 0; - while (true) { - yield* set("tick", i++); - yield* sleep(1000); - } - }); - } - ``` - - A component MAY also run an infinite loop inline, - but this blocks the component body from returning. - The eval loop and the component run concurrently - regardless. +## 9. Components (removed) -P6. Steps within a component (setting properties, installing middleware, -appending children) complete in order. This means middleware and children are -established before the component enters a reactive loop or returns. +Components (`() => Operation`) are removed in this version. Nodes are +created imperatively via `parent.createChild(...)` (§6) and configured through +their synchronous methods; setup that previously lived in a component (e.g. focus +installation) is installed imperatively via `node.scope.around(...)`. --- From 228ab1976a4c24dea719fea9a5699640e4768a59 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sun, 28 Jun 2026 09:05:49 +0300 Subject: [PATCH 20/47] =?UTF-8?q?=E2=9C=A8=20add=20imperative=20createRoot?= =?UTF-8?q?=20+=20synchronous=20Node=20methods=20(additive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/src/lib/mod.ts | 2 ++ packages/freedom/src/lib/node.ts | 55 ++++++++++++++++++++++++++++-- packages/freedom/src/lib/root.ts | 55 ++++++++++++++++++++++++++++++ packages/freedom/src/lib/types.ts | 13 +++++++ packages/freedom/test/root.test.ts | 26 ++++++++++++++ 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 packages/freedom/src/lib/root.ts create mode 100644 packages/freedom/test/root.test.ts diff --git a/packages/freedom/src/lib/mod.ts b/packages/freedom/src/lib/mod.ts index fe18a2d..983d7b7 100644 --- a/packages/freedom/src/lib/mod.ts +++ b/packages/freedom/src/lib/mod.ts @@ -4,11 +4,13 @@ export type { Node, NodeData, NodeDataKey, + Root, Tree, } from "./types.ts"; export { createNodeData } from "./types.ts"; +export { createRoot } from "./root.ts"; export { useTree } from "./tree.ts"; export { diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index 9f4ba44..84fa464 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -3,6 +3,7 @@ import { type Context, createContext, + createScope, Err, Ok, type Operation, @@ -10,6 +11,8 @@ import { type Scope, } from "effection"; import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; +import { TreeContext } from "./state.ts"; +import { validateJsonValue } from "./validate.ts"; class NodeDataImpl implements NodeData { _map: Map = new Map(); @@ -39,13 +42,19 @@ export class NodeImpl implements Node { _children: Set = new Set(); _sortFn: ((a: Node, b: Node) => number) | undefined = undefined; data: NodeData = new NodeDataImpl(); - scope!: Scope; + scope: Scope; + #dispose: () => Promise; constructor( readonly id: string, readonly name: string, readonly _parent: NodeImpl | undefined, - ) {} + ) { + const [scope, dispose] = createScope(_parent?.scope); + this.scope = scope; + this.#dispose = dispose; + scope.set(NodeContext, this); + } get props(): Record { return Object.freeze({ ...this._props }); @@ -100,6 +109,48 @@ export class NodeImpl implements Node { } } + get(key: string): JsonValue | undefined { + return this._props[key]; + } + + set(key: string, value: JsonValue): void { + validateJsonValue(value); + this._props[key] = value; + this.scope.expect(TreeContext).markDirty(); + } + + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void { + const value = fn(this._props[key]); + validateJsonValue(value); + this._props[key] = value; + this.scope.expect(TreeContext).markDirty(); + } + + unset(key: string): void { + if (key in this._props) { + delete this._props[key]; + this.scope.expect(TreeContext).markDirty(); + } + } + + createChild(name = ""): Node { + const state = this.scope.expect(TreeContext); + const child = new NodeImpl(state.nextId(), name, this); + this._children.add(child); + state.nodes.set(child.id, child); + state.markDirty(); + return child; + } + + sort(fn?: (a: Node, b: Node) => number): void { + this._sortFn = fn; + this.scope.expect(TreeContext).markDirty(); + } + + destroy(): Promise { + return this.#dispose(); + } + remove(): Operation { throw new Error("Cannot remove root node"); } diff --git a/packages/freedom/src/lib/root.ts b/packages/freedom/src/lib/root.ts new file mode 100644 index 0000000..5a5a850 --- /dev/null +++ b/packages/freedom/src/lib/root.ts @@ -0,0 +1,55 @@ +import { createSignal } from "effection"; +import type { Root } from "./types.ts"; +import { NodeImpl } from "./node.ts"; +import { TreeContext, type TreeState } from "./state.ts"; +import { DispatchApi } from "./dispatch.ts"; + +export function createRoot(): Root { + const output = createSignal(); + const events = createSignal(); + + let counter = 0; + const state: TreeState = { + dirty: false, + output, + events, + nodes: new Map(), + nextId() { + return `node-${++counter}`; + }, + markDirty() { + state.dirty = true; + }, + }; + + const node = new NodeImpl(state.nextId(), "", undefined); + node.scope.set(TreeContext, state); + state.nodes.set(node.id, node); + + // Dispatch loop: drain events through the demux middleware chain. + node.scope.run(function* () { + const sub = yield* events; + while (true) { + const next = yield* sub.next(); + if (next.done) { + break; + } + state.dirty = false; + yield* DispatchApi.operations.dispatch(next.value); + if (state.dirty) { + output.send(); + } + } + }); + + return { + node, + dispatch(event) { + events.send(event); + }, + [Symbol.iterator]: output[Symbol.iterator], + destroy() { + return node.destroy(); + }, + }; +} diff --git a/packages/freedom/src/lib/types.ts b/packages/freedom/src/lib/types.ts index a3df876..2707c29 100644 --- a/packages/freedom/src/lib/types.ts +++ b/packages/freedom/src/lib/types.ts @@ -35,6 +35,13 @@ export interface Node { readonly children: Iterable; readonly parent: Node | undefined; readonly data: NodeData; + get(key: string): JsonValue | undefined; + set(key: string, value: JsonValue): void; + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; + unset(key: string): void; + createChild(name?: string): Node; + sort(fn?: (a: Node, b: Node) => number): void; + destroy(): Promise; eval(op: () => Operation): Operation>; remove(): Operation; } @@ -43,3 +50,9 @@ export interface Tree extends Stream { dispatch(event: unknown): void; root: Node; } + +export interface Root extends Stream { + node: Node; + dispatch(event: unknown): void; + destroy(): Promise; +} diff --git a/packages/freedom/test/root.test.ts b/packages/freedom/test/root.test.ts new file mode 100644 index 0000000..a1193c7 --- /dev/null +++ b/packages/freedom/test/root.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "../test/suite.ts"; +import { createRoot } from "../src/index.ts"; + +describe("createRoot", () => { + it("returns a root with a parentless node that has an id", () => { + const root = createRoot(); + expect(root.node).toBeTruthy(); + expect(root.node.parent).toBeUndefined(); + expect(root.node.id).toBeTruthy(); + root.destroy(); + }); + + it("createChild attaches a child synchronously with a unique id", () => { + const root = createRoot(); + const a = root.node.createChild("a"); + const b = root.node.createChild("b"); + + expect(a.name).toEqual("a"); + expect(a.parent).toBe(root.node); + expect(a.id).not.toEqual(b.id); + expect(a.id).not.toEqual(root.node.id); + expect([...root.node.children]).toEqual([a, b]); + + root.destroy(); + }); +}); From f39c8d0ef005123c978e33a0b6d9595b5d73a329 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 09:10:24 +0300 Subject: [PATCH 21/47] =?UTF-8?q?=E2=9C=A8=20synchronous=20node=20mutation?= =?UTF-8?q?=20interception=20via=20NodeApi=20+=20scope.around?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/src/lib/mod.ts | 1 + packages/freedom/src/lib/node.ts | 68 +++++++++++++++++++++--------- packages/freedom/src/lib/types.ts | 3 +- packages/freedom/test/root.test.ts | 21 ++++++++- 4 files changed, 71 insertions(+), 22 deletions(-) diff --git a/packages/freedom/src/lib/mod.ts b/packages/freedom/src/lib/mod.ts index 983d7b7..e365650 100644 --- a/packages/freedom/src/lib/mod.ts +++ b/packages/freedom/src/lib/mod.ts @@ -11,6 +11,7 @@ export type { export { createNodeData } from "./types.ts"; export { createRoot } from "./root.ts"; +export { NodeApi } from "./node.ts"; export { useTree } from "./tree.ts"; export { diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index 84fa464..d878f35 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -10,6 +10,7 @@ import { type Result, type Scope, } from "effection"; +import { createApi } from "effection/experimental"; import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; import { TreeContext } from "./state.ts"; import { validateJsonValue } from "./validate.ts"; @@ -110,41 +111,27 @@ export class NodeImpl implements Node { } get(key: string): JsonValue | undefined { - return this._props[key]; + return NodeApi.invoke(this.scope, "get", [this, key]); } set(key: string, value: JsonValue): void { - validateJsonValue(value); - this._props[key] = value; - this.scope.expect(TreeContext).markDirty(); + NodeApi.invoke(this.scope, "set", [this, key, value]); } update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void { - const value = fn(this._props[key]); - validateJsonValue(value); - this._props[key] = value; - this.scope.expect(TreeContext).markDirty(); + NodeApi.invoke(this.scope, "update", [this, key, fn]); } unset(key: string): void { - if (key in this._props) { - delete this._props[key]; - this.scope.expect(TreeContext).markDirty(); - } + NodeApi.invoke(this.scope, "unset", [this, key]); } createChild(name = ""): Node { - const state = this.scope.expect(TreeContext); - const child = new NodeImpl(state.nextId(), name, this); - this._children.add(child); - state.nodes.set(child.id, child); - state.markDirty(); - return child; + return NodeApi.invoke(this.scope, "createChild", [this, name]); } sort(fn?: (a: Node, b: Node) => number): void { - this._sortFn = fn; - this.scope.expect(TreeContext).markDirty(); + NodeApi.invoke(this.scope, "sort", [this, fn]); } destroy(): Promise { @@ -156,6 +143,47 @@ export class NodeImpl implements Node { } } +// Synchronous node mutation API. Core methods take the node first; interceptors +// are installed per scope via `node.scope.around(NodeApi, ...)`. +export const NodeApi = createApi("freedom:node", { + get(node: NodeImpl, key: string): JsonValue | undefined { + return node._props[key]; + }, + set(node: NodeImpl, key: string, value: JsonValue): void { + validateJsonValue(value); + node._props[key] = value; + node.scope.expect(TreeContext).markDirty(); + }, + update( + node: NodeImpl, + key: string, + fn: (prev: JsonValue | undefined) => JsonValue, + ): void { + const value = fn(node._props[key]); + validateJsonValue(value); + node._props[key] = value; + node.scope.expect(TreeContext).markDirty(); + }, + unset(node: NodeImpl, key: string): void { + if (key in node._props) { + delete node._props[key]; + node.scope.expect(TreeContext).markDirty(); + } + }, + createChild(node: NodeImpl, name: string): Node { + const state = node.scope.expect(TreeContext); + const child = new NodeImpl(state.nextId(), name, node); + node._children.add(child); + state.nodes.set(child.id, child); + state.markDirty(); + return child; + }, + sort(node: NodeImpl, fn: ((a: Node, b: Node) => number) | undefined): void { + node._sortFn = fn; + node.scope.expect(TreeContext).markDirty(); + }, +}); + export const NodeContext: Context = createContext( "freedom:current-node", ); diff --git a/packages/freedom/src/lib/types.ts b/packages/freedom/src/lib/types.ts index 2707c29..08bd277 100644 --- a/packages/freedom/src/lib/types.ts +++ b/packages/freedom/src/lib/types.ts @@ -1,4 +1,4 @@ -import type { Operation, Result, Stream } from "effection"; +import type { Operation, Result, Scope, Stream } from "effection"; export type JsonValue = | string @@ -35,6 +35,7 @@ export interface Node { readonly children: Iterable; readonly parent: Node | undefined; readonly data: NodeData; + readonly scope: Scope; get(key: string): JsonValue | undefined; set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; diff --git a/packages/freedom/test/root.test.ts b/packages/freedom/test/root.test.ts index a1193c7..1d2c5fc 100644 --- a/packages/freedom/test/root.test.ts +++ b/packages/freedom/test/root.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "../test/suite.ts"; -import { createRoot } from "../src/index.ts"; +import { createRoot, NodeApi } from "../src/index.ts"; describe("createRoot", () => { it("returns a root with a parentless node that has an id", () => { @@ -23,4 +23,23 @@ describe("createRoot", () => { root.destroy(); }); + + it("a sync interceptor (scope.around NodeApi) transforms a mutation", () => { + const root = createRoot(); + root.node.scope.around(NodeApi, { + set([node, key, value], next) { + next(node, key, typeof value === "number" ? value * 10 : value); + }, + }); + + root.node.set("n", 5); + expect(root.node.props["n"]).toEqual(50); + + // interceptor is inherited by descendants via the scope chain + const child = root.node.createChild("c"); + child.set("m", 2); + expect(child.props["m"]).toEqual(20); + + root.destroy(); + }); }); From 6d806d09d99a169d2de880697fbc3a4c12e38d38 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 09:26:59 +0300 Subject: [PATCH 22/47] =?UTF-8?q?=E2=9C=85=20rewrite=20freedom.test.ts=20i?= =?UTF-8?q?mperatively;=20buffer=20createRoot=20events=20via=20Queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/src/lib/root.ts | 13 +- packages/freedom/src/lib/state.ts | 1 - packages/freedom/src/lib/tree.ts | 1 - packages/freedom/test/freedom.test.ts | 850 +++++++------------------- 4 files changed, 219 insertions(+), 646 deletions(-) diff --git a/packages/freedom/src/lib/root.ts b/packages/freedom/src/lib/root.ts index 5a5a850..062c2b4 100644 --- a/packages/freedom/src/lib/root.ts +++ b/packages/freedom/src/lib/root.ts @@ -1,4 +1,4 @@ -import { createSignal } from "effection"; +import { createQueue, createSignal } from "effection"; import type { Root } from "./types.ts"; import { NodeImpl } from "./node.ts"; import { TreeContext, type TreeState } from "./state.ts"; @@ -6,13 +6,15 @@ import { DispatchApi } from "./dispatch.ts"; export function createRoot(): Root { const output = createSignal(); - const events = createSignal(); + // Internal, always-drained buffer: createRoot is synchronous, so the drain + // loop subscribes asynchronously — a Queue keeps events dispatched before the + // loop runs from being lost (bounded, since the loop drains immediately). + const events = createQueue(); let counter = 0; const state: TreeState = { dirty: false, output, - events, nodes: new Map(), nextId() { return `node-${++counter}`; @@ -28,9 +30,8 @@ export function createRoot(): Root { // Dispatch loop: drain events through the demux middleware chain. node.scope.run(function* () { - const sub = yield* events; while (true) { - const next = yield* sub.next(); + const next = yield* events.next(); if (next.done) { break; } @@ -45,7 +46,7 @@ export function createRoot(): Root { return { node, dispatch(event) { - events.send(event); + events.add(event); }, [Symbol.iterator]: output[Symbol.iterator], destroy() { diff --git a/packages/freedom/src/lib/state.ts b/packages/freedom/src/lib/state.ts index e1a1540..455d1d9 100644 --- a/packages/freedom/src/lib/state.ts +++ b/packages/freedom/src/lib/state.ts @@ -4,7 +4,6 @@ import type { NodeImpl } from "./node.ts"; export interface TreeState { dirty: boolean; output: Signal; - events: Signal; nodes: Map; nextId(): string; markDirty(): void; diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts index e60ac30..2dfebed 100644 --- a/packages/freedom/src/lib/tree.ts +++ b/packages/freedom/src/lib/tree.ts @@ -22,7 +22,6 @@ export function useTree(root: Component): Operation { const state: TreeState = { dirty: false, output, - events, nodes: new Map(), nextId() { return `node-${++counter}`; diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index c196532..2bbe7eb 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -1,682 +1,256 @@ import { describe, expect, it } from "../test/suite.ts"; import { run, sleep } from "effection"; -import { - append, - DispatchApi, - FreedomApi, - get, - set, - sort, - unset, - update, - useNode, - useTree, -} from "../src/index.ts"; +import { createRoot, DispatchApi, NodeApi } from "../src/index.ts"; describe("JsonValue validation", () => { - it("JV1-JV12: accepts valid JsonValues", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - let result = yield* tree.root.eval(function* () { - yield* set("str", "hello"); - yield* set("num", 42); - yield* set("zero", 0); - yield* set("neg", -1.5); - yield* set("bool", true); - yield* set("boolFalse", false); - yield* set("nil", null); - yield* set("arr", [1, "a", true, null]); - yield* set("obj", { a: 1, b: "c" }); - yield* set("nested", { nested: { deep: [1, 2] } }); - yield* set("emptyArr", []); - yield* set("emptyObj", {}); - }); - expect(result.ok).toBe(true); - expect(tree.root.props["str"]).toEqual("hello"); - expect(tree.root.props["num"]).toEqual(42); - expect(tree.root.props["nil"]).toEqual(null); - expect(tree.root.props["nested"]).toEqual({ nested: { deep: [1, 2] } }); - }); - }); - - it("JV13: rejects undefined", async () => { - await run(function* () { - yield* useTree(function* () { - try { - // deno-lint-ignore no-explicit-any - yield* set("k", undefined as any); - expect(true).toBe(false); - } catch (e) { - expect((e as Error).message).toContain("undefined"); - } - }); - }); - }); - - it("JV14-JV16: rejects NaN and Infinity", async () => { - await run(function* () { - yield* useTree(function* () { - for (let val of [NaN, Infinity, -Infinity]) { - try { - yield* set("k", val); - expect(true).toBe(false); - } catch (_e) { - // expected - } - } - }); - }); - }); - - it("JV17-JV20: rejects non-JSON types", async () => { - await run(function* () { - yield* useTree(function* () { - for (let val of [() => {}, Symbol(), new Date(), new Map()]) { - try { - // deno-lint-ignore no-explicit-any - yield* set("k", val as any); - expect(true).toBe(false); - } catch (_e) { - // expected - } - } - }); - }); - }); - - it("JV21-JV23: validates update return values", async () => { - await run(function* () { - yield* useTree(function* () { - yield* set("n", 1); - yield* update("n", () => 42); - - try { - yield* update("n", () => undefined as unknown as number); - expect(true).toBe(false); - } catch (e) { - expect((e as Error).message).toContain("undefined"); - } - }); - }); - }); -}); - -describe("Node lifecycle", () => { - it("NL1-NL6: creates child nodes via append", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* append("child", function* () { - yield* set("init", true); - }); - }); - yield* sleep(0); - - let children = [...tree.root.children]; - expect(children.length).toEqual(1); - expect(children[0].name).toEqual("child"); - expect(children[0].parent).toBe(tree.root); - expect(children[0].props["init"]).toEqual(true); - }); - }); - - it("NL7-NL11: assigns unique ids", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* append("a", function* () {}); - yield* append("b", function* () {}); - }); - yield* sleep(0); - - expect(tree.root.id).toBeTruthy(); - let children = [...tree.root.children]; - expect(children[0].id).not.toEqual(children[1].id); - expect(tree.root.id).not.toEqual(children[0].id); - }); - }); - - it("NL12: remove() removes node from parent", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - let result = yield* tree.root.eval(function* () { - let child = yield* append("child", function* () { - yield* set("name", "child"); - }); - yield* child.remove(); - }); - expect(result.ok).toBe(true); - expect([...tree.root.children].length).toEqual(0); - }); - }); - - it("N12: remove() on root raises error", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - let result = yield* tree.root.eval(function* () { - yield* tree.root.remove(); - }); - expect(result.ok).toBe(false); - }); - }); - - it("NL18: init-only component keeps node alive", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* set("alive", true); - }); - expect(tree.root.props["alive"]).toEqual(true); - }); + it("accepts valid JsonValues", () => { + const root = createRoot(); + const { node } = root; + node.set("str", "hello"); + node.set("num", 42); + node.set("zero", 0); + node.set("neg", -1.5); + node.set("bool", true); + node.set("nil", null); + node.set("arr", [1, "a", true, null]); + node.set("obj", { a: 1, b: "c" }); + node.set("nested", { nested: { deep: [1, 2] } }); + expect(node.props["str"]).toEqual("hello"); + expect(node.props["nil"]).toEqual(null); + expect(node.props["nested"]).toEqual({ nested: { deep: [1, 2] } }); + root.destroy(); + }); + + it("rejects undefined", () => { + const root = createRoot(); + expect(() => root.node.set("k", undefined as unknown as null)).toThrow(); + root.destroy(); + }); + + it("rejects NaN and Infinity", () => { + const root = createRoot(); + for (const val of [NaN, Infinity, -Infinity]) { + expect(() => root.node.set("k", val)).toThrow(); + } + root.destroy(); + }); + + it("rejects non-JSON types", () => { + const root = createRoot(); + for (const val of [() => {}, Symbol(), new Date(), new Map()]) { + expect(() => root.node.set("k", val as unknown as null)).toThrow(); + } + root.destroy(); + }); + + it("validates update return values", () => { + const root = createRoot(); + root.node.set("n", 1); + root.node.update("n", () => 42); + expect(root.node.props["n"]).toEqual(42); + expect(() => root.node.update("n", () => undefined as unknown as number)) + .toThrow(); + root.destroy(); }); }); describe("Property bag", () => { - it("PB1-PB5: set operations", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* set("a", 1); - yield* set("a", 2); - yield* set("b", 2); - yield* set("ns", { x: 1, y: 2 }); - }); - expect(tree.root.props["a"]).toEqual(2); - expect(tree.root.props["b"]).toEqual(2); - expect(tree.root.props["ns"]).toEqual({ x: 1, y: 2 }); - }); - }); - - it("PB6-PB8: update operations", async () => { - await run(function* () { - yield* useTree(function* () { - yield* set("n", 1); - yield* update("n", (v) => (v as number) + 1); - yield* update("missing", (v) => v ?? 0); - }); - }); - }); - - it("PB9: unset removes key", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* set("a", 1); - yield* unset("a"); - }); - expect("a" in tree.root.props).toBe(false); - }); - }); - - it("PB10: unset nonexistent is no-op", async () => { - await run(function* () { - yield* useTree(function* () { - yield* unset("nonexistent"); - }); - }); - }); - - it("PB11: props is read-only", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* set("x", 1); - }); - expect(() => { - // deno-lint-ignore no-explicit-any - (tree.root.props as any)["x"] = 2; - }).toThrow(); - }); + it("set replaces, get reads", () => { + const root = createRoot(); + const { node } = root; + node.set("a", 1); + node.set("a", 2); + node.set("b", 2); + expect(node.get("a")).toEqual(2); + expect(node.props["b"]).toEqual(2); + root.destroy(); + }); + + it("update transforms", () => { + const root = createRoot(); + root.node.set("n", 1); + root.node.update("n", (v) => (v as number) + 1); + expect(root.node.props["n"]).toEqual(2); + root.destroy(); + }); + + it("unset removes the key", () => { + const root = createRoot(); + root.node.set("a", 1); + root.node.unset("a"); + expect("a" in root.node.props).toBe(false); + root.destroy(); + }); + + it("unset of a missing key is a no-op", () => { + const root = createRoot(); + expect(() => root.node.unset("nope")).not.toThrow(); + root.destroy(); + }); + + it("props is read-only", () => { + const root = createRoot(); + root.node.set("x", 1); + expect(() => { + (root.node.props as Record)["x"] = 2; + }).toThrow(); + root.destroy(); }); }); -describe("Child ordering", () => { - it("CO1-CO3: insertion order", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* append("A", function* () {}); - yield* append("B", function* () {}); - yield* append("C", function* () {}); - }); - yield* sleep(0); - - let names = [...tree.root.children].map((c) => c.name); - expect(names).toEqual(["A", "B", "C"]); - }); - }); - - it("CO4-CO6: custom sort", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* sort((a, b) => { - let ap = a.props["priority"] as number; - let bp = b.props["priority"] as number; - return ap - bp; - }); - yield* append("A", function* () { - yield* set("priority", 3); - }); - yield* append("B", function* () { - yield* set("priority", 1); - }); - yield* append("C", function* () { - yield* set("priority", 2); - }); - }); - yield* sleep(0); - - let names = [...tree.root.children].map((c) => c.name); - expect(names).toEqual(["B", "C", "A"]); - }); +describe("Children and ordering", () => { + it("createChild appends in insertion order", () => { + const root = createRoot(); + root.node.createChild("A"); + root.node.createChild("B"); + root.node.createChild("C"); + expect([...root.node.children].map((c) => c.name)).toEqual(["A", "B", "C"]); + root.destroy(); + }); + + it("custom sort reorders children", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + const b = root.node.createChild("B"); + const c = root.node.createChild("C"); + a.set("priority", 3); + b.set("priority", 1); + c.set("priority", 2); + root.node.sort((x, y) => + (x.props["priority"] as number) - (y.props["priority"] as number) + ); + expect([...root.node.children].map((n) => n.name)).toEqual(["B", "C", "A"]); + root.node.sort(undefined); + expect([...root.node.children].map((n) => n.name)).toEqual(["A", "B", "C"]); + root.destroy(); + }); + + it("child ids are unique and the parent is wired", () => { + const root = createRoot(); + const a = root.node.createChild("a"); + const b = a.createChild("b"); + expect(b.parent).toBe(a); + expect(new Set([root.node.id, a.id, b.id]).size).toEqual(3); + root.destroy(); }); }); -describe("Node.eval", () => { - it("runs operations in the node's scope", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* set("before", true); - }); - - let result = yield* tree.root.eval(function* () { - yield* set("after", true); - return 42; - }); - - expect(result).toEqual({ ok: true, value: 42 }); - expect(tree.root.props["after"]).toEqual(true); - }); - }); - - it("captures errors as Result", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - - let result = yield* tree.root.eval(function* () { - throw new Error("boom"); - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toEqual("boom"); - } - }); - }); - - it("re-entrant eval on the current node runs inline without deadlock", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - - let result = yield* tree.root.eval(function* () { - // Re-entrant: eval the current node from inside its own eval loop. - // Routing through the channel here would deadlock. - let inner = yield* tree.root.eval(function* () { - yield* set("inner", true); - return "nested"; - }); - return inner; - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value).toEqual({ ok: true, value: "nested" }); - } - expect(tree.root.props["inner"]).toEqual(true); - }); +describe("Mutation interception (NodeApi)", () => { + it("set interceptor can transform values", () => { + const root = createRoot(); + root.node.scope.around(NodeApi, { + set([node, key, value], next) { + next(node, key, key === "doubled" ? (value as number) * 2 : value); + }, + }); + root.node.set("doubled", 21); + root.node.set("plain", 5); + expect(root.node.props["doubled"]).toEqual(42); + expect(root.node.props["plain"]).toEqual(5); + root.destroy(); + }); + + it("get interceptor can transform reads", () => { + const root = createRoot(); + root.node.set("k", 1); + root.node.scope.around(NodeApi, { + get([node, key], next) { + const val = next(node, key); + return key === "k" ? (val as number) * 10 : val; + }, + }); + expect(root.node.get("k")).toEqual(10); + root.destroy(); + }); + + it("interceptors are inherited by descendants", () => { + const root = createRoot(); + let appended = 0; + root.node.scope.around(NodeApi, { + createChild([node, name], next) { + appended++; + return next(node, name); + }, + }); + const a = root.node.createChild("a"); + a.createChild("b"); + expect(appended).toEqual(2); + root.destroy(); }); }); -describe("Tree and notification", () => { - it("TN1: useTree returns tree with root", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - expect(tree.root).toBeTruthy(); - expect(tree.root.parent).toBeUndefined(); - }); - }); - - it("TN2: root props visible via eval", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* set("ready", true); - }); - expect(tree.root.props["ready"]).toEqual(true); - }); - }); -}); - -describe("Event dispatch", () => { - it("ED1: middleware handles event via root.eval", async () => { +describe("Dispatch and notification", () => { + it("demux middleware handles an event", async () => { await run(function* () { + const root = createRoot(); let handled = false; - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* DispatchApi.around({ - *dispatch([event], next) { - if (event === "ping") { - handled = true; - return { ok: true as const, value: true as const }; - } - return yield* next(event); - }, - }); - }); - tree.dispatch("ping"); - yield* sleep(0); - expect(handled).toBe(true); - }); - }); - - it("ED2: unhandled event", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - tree.dispatch("unknown"); - yield* sleep(0); - expect(tree.root).toBeTruthy(); - }); - }); - - it("ED3: sequential event processing", async () => { - await run(function* () { - let order: string[] = []; - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* DispatchApi.around({ - *dispatch([event], _next) { - order.push(event as string); - yield* set("last", event as string); + root.node.scope.around(DispatchApi, { + *dispatch([event], next) { + if (event === "ping") { + handled = true; return { ok: true as const, value: true as const }; - }, - }); - }); - tree.dispatch("first"); - tree.dispatch("second"); - yield* sleep(0); - yield* sleep(0); - expect(order).toEqual(["first", "second"]); - expect(tree.root.props["last"]).toEqual("second"); - }); - }); - - it("ED4: middleware error captured, tree survives", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* DispatchApi.around({ - *dispatch([_event], _next) { - throw new Error("boom"); - }, - }); + } + return yield* next(event); + }, }); - tree.dispatch("test"); + root.dispatch("ping"); yield* sleep(0); - expect(tree.root).toBeTruthy(); - - // Subsequent dispatch works - tree.dispatch("test2"); - yield* sleep(0); - }); - }); - - it("ED-getNodeById: dispatch middleware resolves target node", async () => { - await run(function* () { - let resolved = false; - let tree = yield* useTree(function* () {}); - - // Install middleware - yield* tree.root.eval(function* () { - yield* DispatchApi.around({ - *dispatch([event], next) { - let ev = event as { type: string; targetId: string }; - if (ev.type === "focus") { - let node = yield* DispatchApi.operations.getNodeById(ev.targetId); - if (node) { - resolved = true; - expect(node.name).toEqual("target"); - expect(node.props["found"]).toEqual(true); - } - return { ok: true as const, value: true as const }; - } - return yield* next(event); - }, - }); - }); - - // Append child — spawn is lazy, so eval again to get the id - // after the child has registered - let id = yield* tree.root.eval(function* () { - let child = yield* append("target", function* () { - yield* set("found", true); - }); - return child.id; - }); - - if (id.ok) { - // By now the child spawn has run (eval sequentializes) - tree.dispatch({ type: "focus", targetId: id.value }); - yield* sleep(0); - expect(resolved).toBe(true); - } else { - expect(true).toBe(false); - } + expect(handled).toBe(true); + yield* root.destroy(); }); }); -}); -describe("Notification coalescing", () => { - it("TN7: multiple sets in one dispatch = one notification", async () => { + it("mutations in a dispatch cycle emit one coalesced notification", async () => { await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* DispatchApi.around({ - *dispatch([_event], _next) { - yield* set("a", 1); - yield* set("b", 2); - yield* set("c", 3); - return { ok: true as const, value: true as const }; - }, - }); + const root = createRoot(); + root.node.scope.around(DispatchApi, { + *dispatch([_event], _next) { + root.node.set("a", 1); + root.node.set("b", 2); + root.node.set("c", 3); + return { ok: true as const, value: true as const }; + }, }); - - let sub = yield* tree; - tree.dispatch("multi-set"); - let next = yield* sub.next(); + const sub = yield* root; + root.dispatch("multi"); + const next = yield* sub.next(); expect(next.done).toBe(false); + expect(root.node.props["a"]).toEqual(1); + yield* root.destroy(); }); }); - it("TN9: no-change dispatch does not notify", async () => { + it("a no-change dispatch does not notify", async () => { await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* DispatchApi.around({ - *dispatch([_event], _next) { - return { ok: true as const, value: true as const }; - }, - }); + const root = createRoot(); + root.node.scope.around(DispatchApi, { + *dispatch([_event], _next) { + return { ok: true as const, value: true as const }; + }, }); - - yield* tree; - tree.dispatch("no-op"); + yield* root; + root.dispatch("noop"); yield* sleep(0); - // No notification emitted — dirty was false - }); - }); -}); - -describe("get operation", () => { - it("GA1: get returns stored value", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - yield* set("k", 42); - let val = yield* get("k"); - expect(val).toEqual(42); - }); - }); - }); - - it("GA2: get returns undefined for missing key", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - yield* tree.root.eval(function* () { - let val = yield* get("missing"); - expect(val).toBeUndefined(); - }); + yield* root.destroy(); }); }); - it("GA3: get middleware can intercept reads", async () => { + it("processes events sequentially", async () => { await run(function* () { - let tree = yield* useTree(function* () { - yield* set("k", 1); - yield* FreedomApi.around({ - *get([key], next) { - let val = yield* next(key); - if (key === "k") { - return (val as number) * 10; - } - return val; - }, - }); + const root = createRoot(); + const order: string[] = []; + root.node.scope.around(DispatchApi, { + *dispatch([event], _next) { + order.push(event as string); + return { ok: true as const, value: true as const }; + }, }); + root.dispatch("first"); + root.dispatch("second"); yield* sleep(0); - let result = yield* tree.root.eval(function* () { - return yield* get("k"); - }); - expect(result).toEqual({ ok: true, value: 10 }); - }); - }); -}); - -describe("remove operation", () => { - it("RA1: remove destroys child node", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - let result = yield* tree.root.eval(function* () { - let child = yield* append("child", function* () {}); - yield* child.remove(); - }); - expect(result.ok).toBe(true); - expect([...tree.root.children].length).toEqual(0); - }); - }); - - it("RA2: remove on root raises error", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - let result = yield* tree.root.eval(function* () { - yield* tree.root.remove(); - }); - expect(result.ok).toBe(false); - }); - }); - - it("RA3: remove middleware can intercept removal", async () => { - await run(function* () { - let intercepted = false; - let tree = yield* useTree(function* () { - yield* FreedomApi.around({ - *remove([node], next) { - intercepted = true; - yield* next(node); - }, - }); - }); - yield* tree.root.eval(function* () { - let child = yield* append("child", function* () {}); - yield* child.remove(); - }); - expect(intercepted).toBe(true); - }); - }); - - it("RA4: remove middleware runs before teardown", async () => { - await run(function* () { - let nameBeforeTeardown = ""; - let tree = yield* useTree(function* () { - yield* FreedomApi.around({ - *remove([node], next) { - nameBeforeTeardown = node.name; - let found = [...tree.root.children].find( - (c) => c.name === node.name, - ); - expect(found).toBeTruthy(); - yield* next(node); - }, - }); - }); - yield* tree.root.eval(function* () { - let child = yield* append("target", function* () {}); - yield* child.remove(); - }); - expect(nameBeforeTeardown).toEqual("target"); - }); - }); -}); - -describe("useNode operation", () => { - it("UN1: returns root node in root component", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - let node = yield* useNode(); - expect(node).toBe(tree.root); - }); - }); - }); - - it("UN2: returns child node in child component", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* append("child", function* () { - let node = yield* useNode(); - expect(node).not.toBe(tree.root); - expect(node.name).toEqual("child"); - }); - }); yield* sleep(0); - }); - }); - - it("UN3: returns eval target node via node.eval", async () => { - await run(function* () { - let tree = yield* useTree(function* () {}); - let child = yield* tree.root.eval(function* () { - return yield* append("target", function* () {}); - }); - if (!child.ok) throw child.error; - - let result = yield* child.value.eval(function* () { - return yield* useNode(); - }); - if (!result.ok) throw result.error; - - expect(result.value).toBe(child.value); - }); - }); - - it("UN4: middleware can intercept useNode", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* FreedomApi.around({ - *useNode(_, next) { - let node = yield* next(); - return node; - }, - }); - }); - - let intercepted = false; - yield* tree.root.eval(function* () { - let child = yield* append("child", function* () {}); - yield* FreedomApi.around({ - *useNode(_, next) { - intercepted = true; - return yield* next(); - }, - }); - yield* child.eval(function* () { - yield* useNode(); - }); - }); - expect(intercepted).toBe(true); + expect(order).toEqual(["first", "second"]); + yield* root.destroy(); }); }); }); From 0b763be953b5d95a18922907eb2d4b2f2561cff4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 09:41:36 +0300 Subject: [PATCH 23/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rewrite=20focus=20as?= =?UTF-8?q?=20synchronous;=20remove=20old=20async=20API=20(FreedomApi/useT?= =?UTF-8?q?ree/append/eval)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - focus.ts: sync functions taking a node; useFocus installs a remove-interceptor via node.scope.around(NodeApi) - NodeApi gains sync remove; Node.remove() returns the teardown Promise - delete freedom.ts (FreedomApi/append/operations) and tree.ts (useTree) - drop Component/Tree/Node.eval from types; mod.ts exports the new surface - focus.test.ts rewritten imperatively (synchronous) --- packages/freedom/src/lib/focus.ts | 172 ++++----- packages/freedom/src/lib/freedom.ts | 124 ------ packages/freedom/src/lib/mod.ts | 18 - packages/freedom/src/lib/node.ts | 45 +-- packages/freedom/src/lib/tree.ts | 111 ------ packages/freedom/src/lib/types.ts | 12 +- packages/freedom/test/focus.test.ts | 561 ++++++++-------------------- 7 files changed, 245 insertions(+), 798 deletions(-) delete mode 100644 packages/freedom/src/lib/freedom.ts delete mode 100644 packages/freedom/src/lib/tree.ts diff --git a/packages/freedom/src/lib/focus.ts b/packages/freedom/src/lib/focus.ts index a30effa..4c7d797 100644 --- a/packages/freedom/src/lib/focus.ts +++ b/packages/freedom/src/lib/focus.ts @@ -1,18 +1,6 @@ // oxlint-disable bombshell-dev/no-generic-error -// oxlint-disable max-params -import type { Api, Operation } from "effection"; -import { createApi } from "effection/experimental"; import type { Node } from "./types.ts"; -import { FreedomApi, set } from "./freedom.ts"; -import { NodeContext, type NodeImpl } from "./node.ts"; - -export interface Focus { - focusable(): Operation; - advance(): Operation; - retreat(): Operation; - focus(node: Node): Operation; - current(): Operation; -} +import { NodeApi } from "./node.ts"; function findRoot(node: Node): Node { let n = node; @@ -33,108 +21,80 @@ function focusChain(node: Node): Node[] { return result; } -function* setFocused( - target: Node, - value: boolean, - self: NodeImpl, -): Operation { - if (target === self) { - yield* set("focused", value); - } else { - yield* target.eval(() => set("focused", value)); +function successorOf(node: Node): Node | undefined { + const nodes = focusChain(findRoot(node)); + if (nodes.length <= 1) { + return undefined; } + const idx = nodes.indexOf(node); + if (idx === -1) { + return undefined; + } + return nodes[(idx + 1) % nodes.length]; } -export const FocusApi: Api = createApi("freedom:focus", { - *focusable() { - const node = yield* NodeContext.expect(); - if (!("focused" in node.props)) { - yield* set("focused", false); - } - }, - - *advance() { - const self = yield* NodeContext.expect(); - const r = findRoot(self); - const nodes = focusChain(r); - if (nodes.length <= 1) return; - - const idx = nodes.findIndex((n) => n.props.focused === true); - if (idx === -1) return; - - const old = nodes[idx]; - const next = nodes[(idx + 1) % nodes.length]; - - yield* setFocused(old, false, self); - yield* setFocused(next, true, self); - }, - - *retreat() { - const self = yield* NodeContext.expect(); - const r = findRoot(self); - const nodes = focusChain(r); - if (nodes.length <= 1) return; - - const idx = nodes.findIndex((n) => n.props.focused === true); - if (idx === -1) return; - - const old = nodes[idx]; - const prev = nodes[(idx - 1 + nodes.length) % nodes.length]; - - yield* setFocused(old, false, self); - yield* setFocused(prev, true, self); - }, - - *focus(target: Node) { - if (!("focused" in target.props)) { - throw new Error("Cannot focus a non-focusable node"); - } - if (target.props.focused === true) return; - - const self = yield* NodeContext.expect(); - const r = findRoot(target); - const nodes = focusChain(r); - const old = nodes.find((n) => n.props.focused === true); +export function focusable(node: Node): void { + if (!("focused" in node.props)) { + node.set("focused", false); + } +} - if (old) { - yield* setFocused(old, false, self); - } - yield* setFocused(target, true, self); - }, +export function current(node: Node): Node { + const root = findRoot(node); + return focusChain(root).find((n) => n.props.focused === true) ?? root; +} - *current() { - const node = yield* NodeContext.expect(); - const r = findRoot(node); - const nodes = focusChain(r); - const focused = nodes.find((n) => n.props.focused === true); - if (focused) { - return focused; - } else { - return r; - } - }, -}); +export function advance(node: Node): void { + const nodes = focusChain(findRoot(node)); + if (nodes.length <= 1) { + return; + } + const idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) { + return; + } + nodes[idx].set("focused", false); + nodes[(idx + 1) % nodes.length].set("focused", true); +} -export const focusable: typeof FocusApi.operations.focusable = - FocusApi.operations.focusable; -export const advance: typeof FocusApi.operations.advance = - FocusApi.operations.advance; -export const retreat: typeof FocusApi.operations.retreat = - FocusApi.operations.retreat; -export const focus: typeof FocusApi.operations.focus = - FocusApi.operations.focus; -export const current: typeof FocusApi.operations.current = - FocusApi.operations.current; +export function retreat(node: Node): void { + const nodes = focusChain(findRoot(node)); + if (nodes.length <= 1) { + return; + } + const idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) { + return; + } + nodes[idx].set("focused", false); + nodes[(idx - 1 + nodes.length) % nodes.length].set("focused", true); +} -export function* useFocus(): Operation { - yield* set("focused", true); +export function focus(target: Node): void { + if (!("focused" in target.props)) { + throw new Error("Cannot focus a non-focusable node"); + } + if (target.props.focused === true) { + return; + } + const old = focusChain(findRoot(target)).find((n) => n.props.focused === true); + if (old) { + old.set("focused", false); + } + target.set("focused", true); +} - yield* FreedomApi.around({ - *remove([node], next) { - if (node.props.focused === true) { - yield* FocusApi.operations.advance(); +export function useFocus(node: Node): void { + node.set("focused", true); + node.scope.around(NodeApi, { + remove([target], next) { + if (target.props.focused === true) { + const successor = successorOf(target); + if (successor && successor !== target) { + focus(successor); + } } - yield* next(node); + return next(target); }, }); } diff --git a/packages/freedom/src/lib/freedom.ts b/packages/freedom/src/lib/freedom.ts deleted file mode 100644 index 858fd46..0000000 --- a/packages/freedom/src/lib/freedom.ts +++ /dev/null @@ -1,124 +0,0 @@ -// oxlint-disable require-yield -// oxlint-disable bombshell-dev/no-generic-error -import { - type Api, - type Operation, - spawn, - suspend, - useScope, - withResolvers, -} from "effection"; -import { createApi } from "effection/experimental"; -import { - type Component, - createNodeData, - type JsonValue, - type Node, -} from "./types.ts"; -import { NodeContext, NodeImpl } from "./node.ts"; -import { TreeContext } from "./state.ts"; -import { validateJsonValue } from "./validate.ts"; - -const Halt = createNodeData<() => Operation>( - "freedom:halt", - function* () { - throw new Error("Cannot remove root node"); - }, -); - -export interface Freedom { - useNode(): Operation; - get(key: string): Operation; - set(key: string, value: JsonValue): Operation; - update( - key: string, - fn: (prev: JsonValue | undefined) => JsonValue, - ): Operation; - unset(key: string): Operation; - append(name: string, component: Component): Operation; - remove(node: Node): Operation; - sort(fn: ((a: Node, b: Node) => number) | undefined): Operation; -} - -export const FreedomApi: Api = createApi("freedom:node", { - useNode: () => NodeContext.expect(), - - *get(key: string): Operation { - const node = yield* NodeContext.expect(); - return node._props[key]; - }, - - *set(key: string, value: JsonValue) { - validateJsonValue(value); - const node = yield* NodeContext.expect(); - node._props[key] = value; - }, - - *update(key: string, fn: (prev: JsonValue | undefined) => JsonValue) { - const node = yield* NodeContext.expect(); - const prev = node._props[key]; - const next = fn(prev); - validateJsonValue(next); - node._props[key] = next; - }, - - *unset(key: string) { - const node = yield* NodeContext.expect(); - if (key in node._props) { - delete node._props[key]; - } - }, - - *append(name: string, component: Component): Operation { - const parent = yield* NodeContext.expect(); - const tree = yield* TreeContext.expect(); - const child = new NodeImpl(tree.nextId(), name, parent); - const ready = withResolvers(); - - const task = yield* spawn(function* () { - parent._children.add(child); - tree.nodes.set(child.id, child); - yield* NodeContext.set(child); - child.scope = yield* useScope(); - ready.resolve(); - try { - yield* component(); - yield* suspend(); - } finally { - parent._children.delete(child); - tree.nodes.delete(child.id); - tree.markDirty(); - } - }); - child.data.set(Halt, task.halt); - child.remove = () => FreedomApi.operations.remove(child); - - yield* ready.operation; - return child; - }, - - *remove(node: Node) { - const halt = node.data.expect(Halt); - yield* halt(); - }, - - *sort(fn: ((a: Node, b: Node) => number) | undefined) { - const node = yield* NodeContext.expect(); - node._sortFn = fn; - }, -}); - -export const useNode: typeof FreedomApi.operations.useNode = - FreedomApi.operations.useNode; -export const get: typeof FreedomApi.operations.get = FreedomApi.operations.get; -export const set: typeof FreedomApi.operations.set = FreedomApi.operations.set; -export const update: typeof FreedomApi.operations.update = - FreedomApi.operations.update; -export const unset: typeof FreedomApi.operations.unset = - FreedomApi.operations.unset; -export const append: typeof FreedomApi.operations.append = - FreedomApi.operations.append; -export const remove: typeof FreedomApi.operations.remove = - FreedomApi.operations.remove; -export const sort: typeof FreedomApi.operations.sort = - FreedomApi.operations.sort; diff --git a/packages/freedom/src/lib/mod.ts b/packages/freedom/src/lib/mod.ts index e365650..b868cdf 100644 --- a/packages/freedom/src/lib/mod.ts +++ b/packages/freedom/src/lib/mod.ts @@ -1,41 +1,23 @@ export type { - Component, JsonValue, Node, NodeData, NodeDataKey, Root, - Tree, } from "./types.ts"; export { createNodeData } from "./types.ts"; export { createRoot } from "./root.ts"; export { NodeApi } from "./node.ts"; -export { useTree } from "./tree.ts"; - -export { - append, - type Freedom, - FreedomApi, - get, - remove, - set, - sort, - unset, - update, - useNode, -} from "./freedom.ts"; export { type Dispatch, DispatchApi } from "./dispatch.ts"; export { advance, current, - type Focus, focus, focusable, - FocusApi, retreat, useFocus, } from "./focus.ts"; diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index d878f35..4e8211f 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -4,10 +4,6 @@ import { type Context, createContext, createScope, - Err, - Ok, - type Operation, - type Result, type Scope, } from "effection"; import { createApi } from "effection/experimental"; @@ -83,33 +79,6 @@ export class NodeImpl implements Node { return this._parent; } - *eval(op: () => Operation): Operation> { - // Run `op` inline with the current routine's scope temporarily repointed at - // this node's scope, so the op sees this node's contexts/middleware. - const restore = (yield { - description: "freedom: enter node scope", - enter: ( - resolve: (result: Result<() => void>) => void, - routine: { scope: Scope }, - ) => { - const original = routine.scope; - routine.scope = this.scope; - resolve(Ok(() => { - routine.scope = original; - })); - return (resolveExit: (result: Result) => void) => - resolveExit(Ok()); - }, - }) as () => void; - try { - return Ok(yield* op()); - } catch (error) { - return Err(error as Error); - } finally { - restore(); - } - } - get(key: string): JsonValue | undefined { return NodeApi.invoke(this.scope, "get", [this, key]); } @@ -138,8 +107,8 @@ export class NodeImpl implements Node { return this.#dispose(); } - remove(): Operation { - throw new Error("Cannot remove root node"); + remove(): Promise { + return NodeApi.invoke(this.scope, "remove", [this]); } } @@ -182,6 +151,16 @@ export const NodeApi = createApi("freedom:node", { node._sortFn = fn; node.scope.expect(TreeContext).markDirty(); }, + remove(node: NodeImpl): Promise { + if (!node._parent) { + throw new Error("Cannot remove root node"); + } + const state = node.scope.expect(TreeContext); + node._parent._children.delete(node); + state.nodes.delete(node.id); + state.markDirty(); + return node.destroy(); + }, }); export const NodeContext: Context = createContext( diff --git a/packages/freedom/src/lib/tree.ts b/packages/freedom/src/lib/tree.ts deleted file mode 100644 index 2dfebed..0000000 --- a/packages/freedom/src/lib/tree.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - createSignal, - type Operation, - resource, - spawn, - suspend, - useScope, - withResolvers, -} from "effection"; -import type { Component, Tree } from "./types.ts"; -import { NodeContext, NodeImpl } from "./node.ts"; -import { TreeContext, type TreeState } from "./state.ts"; -import { DispatchApi } from "./dispatch.ts"; -import { FreedomApi } from "./freedom.ts"; - -export function useTree(root: Component): Operation { - return resource(function* (provide) { - const output = createSignal(); - const events = createSignal(); - - let counter = 0; - const state: TreeState = { - dirty: false, - output, - nodes: new Map(), - nextId() { - return `node-${++counter}`; - }, - markDirty() { - state.dirty = true; - }, - }; - - yield* TreeContext.set(state); - - const rootNode = new NodeImpl(state.nextId(), "", undefined); - rootNode.remove = () => FreedomApi.operations.remove(rootNode); - state.nodes.set(rootNode.id, rootNode); - - const ready = withResolvers(); - - // Spawn root node scope - yield* spawn(function* () { - rootNode.scope = yield* useScope(); - yield* NodeContext.set(rootNode); - - // Mark dirty after every mutation - yield* FreedomApi.around({ - *set(args, next) { - yield* next(...args); - state.markDirty(); - }, - *update(args, next) { - yield* next(...args); - state.markDirty(); - }, - *unset(args, next) { - yield* next(...args); - state.markDirty(); - }, - *append(args, next) { - const node = yield* next(...args); - state.markDirty(); - return node; - }, - *remove(args, next) { - yield* next(...args); - state.markDirty(); - }, - *sort(args, next) { - yield* next(...args); - state.markDirty(); - }, - }); - - // Subscribe to events, then spawn the event loop - const sub = yield* events; - yield* spawn(function* () { - while (true) { - const next = yield* sub.next(); - if (next.done) { - break; - } - const event = next.value; - state.dirty = false; - yield* rootNode.eval(() => DispatchApi.operations.dispatch(event)); - if (state.dirty) { - output.send(); - } - } - }); - - ready.resolve(); - - yield* root(); - yield* suspend(); - }); - - yield* ready.operation; - - const tree: Tree = { - dispatch(event: unknown) { - events.send(event); - }, - root: rootNode, - [Symbol.iterator]: output[Symbol.iterator], - }; - - yield* provide(tree); - }); -} diff --git a/packages/freedom/src/lib/types.ts b/packages/freedom/src/lib/types.ts index 08bd277..241eb8a 100644 --- a/packages/freedom/src/lib/types.ts +++ b/packages/freedom/src/lib/types.ts @@ -1,4 +1,4 @@ -import type { Operation, Result, Scope, Stream } from "effection"; +import type { Scope, Stream } from "effection"; export type JsonValue = | string @@ -8,8 +8,6 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -export type Component = () => Operation; - export interface NodeDataKey { readonly symbol: symbol; readonly defaultValue?: T; @@ -43,13 +41,7 @@ export interface Node { createChild(name?: string): Node; sort(fn?: (a: Node, b: Node) => number): void; destroy(): Promise; - eval(op: () => Operation): Operation>; - remove(): Operation; -} - -export interface Tree extends Stream { - dispatch(event: unknown): void; - root: Node; + remove(): Promise; } export interface Root extends Stream { diff --git a/packages/freedom/test/focus.test.ts b/packages/freedom/test/focus.test.ts index cb80c57..651325d 100644 --- a/packages/freedom/test/focus.test.ts +++ b/packages/freedom/test/focus.test.ts @@ -1,432 +1,201 @@ -import { describe, it } from "../test/suite.ts"; -import { expect } from "../test/helpers.ts"; -import { run } from "effection"; +import { describe, expect, it } from "../test/suite.ts"; import { advance, - append, + createRoot, current, focus, focusable, retreat, - set, useFocus, - useTree, } from "../src/index.ts"; describe("Focus installation", () => { - it("FI1-FI3: useFocus sets root as focused", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - }); - - yield* expect(tree.root).toEval(function* () { - let node = yield* current(); - expect(node).toBe(tree.root); - expect(tree.root.props.focused).toBe(true); - }); - }); + it("useFocus sets root as focused", () => { + const root = createRoot(); + useFocus(root.node); + expect(current(root.node)).toBe(root.node); + expect(root.node.props.focused).toBe(true); + root.destroy(); }); }); describe("focusable()", () => { - it("FF1-FF2: focusable sets focused:false on the node", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("child", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - expect(children[0].props.focused).toBe(false); - }); - }); - }); - - it("FF3: focusable on already-focusable node is no-op", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("child", function* () { - yield* focusable(); - yield* focusable(); // second call - }); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - expect(children[0].props.focused).toBe(false); - }); - }); - }); - - it("FF4: node without focusable is not in focus chain", async () => { - await run(function* () { - yield* useTree(function* () { - yield* useFocus(); - yield* append("nonfocusable", function* () { - yield* set("label", "skip me"); - }); - yield* append("focusable", function* () { - yield* focusable(); - }); - }); - - // advance from root should skip nonfocusable child - // (eval sequences after component has run) - }); + it("sets focused:false on the node", () => { + const root = createRoot(); + useFocus(root.node); + const child = root.node.createChild("child"); + focusable(child); + expect(child.props.focused).toBe(false); + root.destroy(); + }); + + it("is a no-op on an already-focusable node", () => { + const root = createRoot(); + useFocus(root.node); + const child = root.node.createChild("child"); + focusable(child); + focusable(child); + expect(child.props.focused).toBe(false); + root.destroy(); + }); + + it("a node without focusable is skipped by the chain", () => { + const root = createRoot(); + useFocus(root.node); + root.node.createChild("skip"); + focusable(root.node.createChild("here")); + advance(root.node); // root -> here, skipping "skip" + expect(current(root.node).name).toEqual("here"); + root.destroy(); }); }); describe("Focus chain", () => { - it("FC1: depth-first order with flat children", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - yield* append("B", function* () { - yield* focusable(); - }); - yield* append("C", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let names: string[] = []; - for (let i = 0; i < 4; i++) { - let node = yield* current(); - names.push(node.name); - yield* advance(); - } - expect(names).toEqual(["", "A", "B", "C"]); - }); - }); - }); - - it("FC2: depth-first order with nested children", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - yield* append("A1", function* () { - yield* focusable(); - }); - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let names: string[] = []; - for (let i = 0; i < 4; i++) { - let node = yield* current(); - names.push(node.name); - yield* advance(); - } - expect(names).toEqual(["", "A", "A1", "B"]); - }); - }); - }); - - it("FC3: non-focusable nodes are skipped", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - // not focusable - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let names: string[] = []; - for (let i = 0; i < 2; i++) { - let node = yield* current(); - names.push(node.name); - yield* advance(); - } - expect(names).toEqual(["", "B"]); - }); - }); + it("depth-first order, flat children", () => { + const root = createRoot(); + useFocus(root.node); + for (const name of ["A", "B", "C"]) { + focusable(root.node.createChild(name)); + } + const names: string[] = []; + for (let i = 0; i < 4; i++) { + names.push(current(root.node).name); + advance(root.node); + } + expect(names).toEqual(["", "A", "B", "C"]); + root.destroy(); + }); + + it("depth-first order, nested children", () => { + const root = createRoot(); + useFocus(root.node); + const a = root.node.createChild("A"); + focusable(a); + focusable(a.createChild("A1")); + focusable(root.node.createChild("B")); + const names: string[] = []; + for (let i = 0; i < 4; i++) { + names.push(current(root.node).name); + advance(root.node); + } + expect(names).toEqual(["", "A", "A1", "B"]); + root.destroy(); }); }); describe("advance()", () => { - it("FA1-FA3: moves focus forward", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - expect(tree.root.props.focused).toBe(true); - yield* advance(); - expect(tree.root.props.focused).toBe(false); - let children = [...tree.root.children]; - expect(children[0].props.focused).toBe(true); - }); - }); - }); - - it("FA4: wraps from last to first", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - yield* advance(); - yield* advance(); - let node = yield* current(); - expect(node).toBe(tree.root); - expect(tree.root.props.focused).toBe(true); - }); - }); - }); - - it("FA5: single focusable node is a no-op", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - }); - - yield* expect(tree.root).toEval(function* () { - yield* advance(); - let node = yield* current(); - expect(node).toBe(tree.root); - expect(tree.root.props.focused).toBe(true); - }); - }); + it("moves focus forward", () => { + const root = createRoot(); + useFocus(root.node); + const a = root.node.createChild("A"); + focusable(a); + expect(root.node.props.focused).toBe(true); + advance(root.node); + expect(root.node.props.focused).toBe(false); + expect(a.props.focused).toBe(true); + root.destroy(); + }); + + it("wraps from last to first (root)", () => { + const root = createRoot(); + useFocus(root.node); + focusable(root.node.createChild("A")); + advance(root.node); // root -> A + advance(root.node); // A -> root + expect(current(root.node)).toBe(root.node); + root.destroy(); + }); + + it("single focusable node is a no-op", () => { + const root = createRoot(); + useFocus(root.node); + advance(root.node); + expect(current(root.node)).toBe(root.node); + root.destroy(); }); }); describe("retreat()", () => { - it("FR1: moves focus backward", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - yield* advance(); // root -> A - yield* advance(); // A -> B - let node = yield* current(); - expect(node.name).toEqual("B"); - - yield* retreat(); // B -> A - node = yield* current(); - expect(node.name).toEqual("A"); - }); - }); - }); - - it("FR2: wraps from first to last", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - yield* retreat(); - let node = yield* current(); - expect(node.name).toEqual("B"); - }); - }); - }); - - it("FR3: single node is a no-op", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - }); - - yield* expect(tree.root).toEval(function* () { - yield* retreat(); - let node = yield* current(); - expect(node).toBe(tree.root); - }); - }); + it("moves focus backward", () => { + const root = createRoot(); + useFocus(root.node); + focusable(root.node.createChild("A")); + focusable(root.node.createChild("B")); + advance(root.node); // root -> A + advance(root.node); // A -> B + expect(current(root.node).name).toEqual("B"); + retreat(root.node); // B -> A + expect(current(root.node).name).toEqual("A"); + root.destroy(); + }); + + it("wraps from first to last", () => { + const root = createRoot(); + useFocus(root.node); + focusable(root.node.createChild("A")); + const b = root.node.createChild("B"); + focusable(b); + retreat(root.node); // root -> B (last) + expect(current(root.node)).toBe(b); + root.destroy(); }); }); describe("focus(node)", () => { - it("FE1: explicit focus changes focused node", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - let b = children[1]; - yield* focus(b); - let node = yield* current(); - expect(node).toBe(b); - expect(tree.root.props.focused).toBe(false); - expect(b.props.focused).toBe(true); - }); - }); - }); - - it("FE2: focus on non-focusable node raises error", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("nonfocusable", function* () {}); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - try { - yield* focus(children[0]); - expect(true).toBe(false); - } catch (e) { - expect((e as Error).message).toContain("non-focusable"); - } - }); - }); - }); - - it("FE3: focus on already-focused node is no-op", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - }); - - yield* expect(tree.root).toEval(function* () { - yield* focus(tree.root); - let node = yield* current(); - expect(node).toBe(tree.root); - expect(tree.root.props.focused).toBe(true); - }); - }); - }); -}); - -describe("current()", () => { - it("CU1-CU3: returns the focused node", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let node = yield* current(); - expect(node).toBe(tree.root); - - yield* advance(); - node = yield* current(); - expect(node.name).toEqual("A"); - }); - }); + it("explicitly focuses a node", () => { + const root = createRoot(); + useFocus(root.node); + focusable(root.node.createChild("A")); + const b = root.node.createChild("B"); + focusable(b); + focus(b); + expect(current(root.node)).toBe(b); + expect(root.node.props.focused).toBe(false); + expect(b.props.focused).toBe(true); + root.destroy(); + }); + + it("throws on a non-focusable node", () => { + const root = createRoot(); + useFocus(root.node); + const child = root.node.createChild("nope"); + expect(() => focus(child)).toThrow(); + root.destroy(); + }); + + it("is a no-op when already focused", () => { + const root = createRoot(); + useFocus(root.node); + focus(root.node); + expect(current(root.node)).toBe(root.node); + root.destroy(); }); }); describe("Focused node removal", () => { - it("FR1: removing focused node advances focus", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - let a = children[0]; - yield* focus(a); - expect(a.props.focused).toBe(true); - yield* a.remove(); - - let node = yield* current(); - expect(node.name).toEqual("B"); - }); - }); - }); - - it("FR4: removing non-focused node does not move focus", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - yield* append("B", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - let b = children[1]; - yield* b.remove(); - let node = yield* current(); - expect(node).toBe(tree.root); - }); - }); - }); - - it("FR5: removing only non-root focusable returns focus to root", async () => { - await run(function* () { - let tree = yield* useTree(function* () { - yield* useFocus(); - yield* append("A", function* () { - yield* focusable(); - }); - }); - - yield* expect(tree.root).toEval(function* () { - let children = [...tree.root.children]; - let a = children[0]; - yield* focus(a); - expect(a.props.focused).toBe(true); - yield* a.remove(); - - let node = yield* current(); - expect(node).toBe(tree.root); - expect(tree.root.props.focused).toBe(true); - }); - }); + it("removing the focused node advances focus", async () => { + const root = createRoot(); + useFocus(root.node); + const a = root.node.createChild("A"); + focusable(a); + focusable(root.node.createChild("B")); + focus(a); + expect(a.props.focused).toBe(true); + + await a.remove(); + expect(current(root.node).name).toEqual("B"); + root.destroy(); + }); + + it("removing a non-focused node does not move focus", async () => { + const root = createRoot(); + useFocus(root.node); + focusable(root.node.createChild("A")); + const b = root.node.createChild("B"); + focusable(b); + + await b.remove(); + expect(current(root.node)).toBe(root.node); + root.destroy(); }); }); From e9e63daac697e1182d66a02bd91a9be873640fad Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 09:46:53 +0300 Subject: [PATCH 24/47] =?UTF-8?q?=F0=9F=93=9D=20spec:=20reconcile=20remove?= =?UTF-8?q?()=20Promise=20+=20synchronous=20focus=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/freedom/specs/freedom-focus-spec.md | 90 +++++++++----------- packages/freedom/specs/freedom-spec.md | 13 +-- 2 files changed, 49 insertions(+), 54 deletions(-) diff --git a/packages/freedom/specs/freedom-focus-spec.md b/packages/freedom/specs/freedom-focus-spec.md index fd5831b..d952b58 100644 --- a/packages/freedom/specs/freedom-focus-spec.md +++ b/packages/freedom/specs/freedom-focus-spec.md @@ -41,9 +41,10 @@ notification. No special subscription mechanism is needed. ### 2.4 Built on Freedom APIs -All focus operations use Freedom's context APIs. Focus movement uses -`node.eval()` to run `set` and `unset` in the correct node scopes. Focus cleanup -uses middleware on `remove`. No internal state is accessed directly. +Focus operations are **synchronous functions** that take a node and use the +node's synchronous methods. Focus movement calls `node.set("focused", …)` +directly. Focus cleanup installs a synchronous interceptor on `remove` via +`node.scope.around(NodeApi, …)`. No internal state is accessed directly. --- @@ -68,18 +69,22 @@ stored. ### 4.1 API Definition -The focus API is an Effection API created with `createApi`: +Focus is a set of **synchronous functions** that take a `Node` (used to locate +the tree root), plus `useFocus` to install it: ``` -createApi("freedom:focus", { - *focusable(): Operation, - *advance(): Operation, - *retreat(): Operation, - *focus(node: Node): Operation, - *current(): Operation, -}) +focusable(node: Node): void +advance(node: Node): void +retreat(node: Node): void +focus(target: Node): void +current(node: Node): Node +useFocus(node: Node): void ``` +These are plain functions (not `createApi` operations) and are not +middleware-interceptable in this version; focus trapping and the like remain +deferred (§9). Focus movement is performed with `node.set("focused", …)`. + ### 4.2 Operations **focusable** @@ -193,63 +198,52 @@ position in the chain. ### 6.1 Installation -FL1. Focus is installed by calling `yield* useFocus()` in the root component: +FL1. Focus is installed by calling `useFocus(root.node)`: ```ts - function* app(): Operation { - yield* useFocus(); - // ... rest of app - } + const root = createRoot(); + useFocus(root.node); ``` -FL2. `useFocus()` performs two actions: 1. Sets `focused: true` on the root -node, making it the initial focus target. 2. Installs middleware on -`freedom:node`'s `remove` operation to handle focused node removal (§6.3). - -FL3. `useFocus()` is an Effection resource operation. It runs within the root -node's scope, so its middleware is visible to all descendants. +FL2. `useFocus(node)` performs two actions: 1. Sets `focused: true` on the node, +making it the initial focus target. 2. Installs a synchronous `remove` +interceptor on the node's scope (`node.scope.around(NodeApi, …)`) to handle +focused-node removal (§6.3). Because it is installed on the node's scope, the +interceptor applies to all descendants. ### 6.2 Focus movement -FL4. When focus moves from node A to node B, the focus system executes: - - ```ts - yield* oldNode.eval(() => set("focused", false)); - yield* newNode.eval(() => set("focused", true)); - ``` +FL4. When focus moves from node A to node B, the focus system calls +`A.set("focused", false)` and `B.set("focused", true)` — synchronous mutations. -FL5. Both property changes go through the `freedom:node` context API. Middleware -on `set` sees focus changes and can react to or redirect them. +FL5. Both changes go through `NodeApi`. Interceptors on `set` see focus changes +and may react to or redirect them. -FL6. Both property changes trigger `markDirty()` via Freedom's existing -notification middleware. The changes coalesce into a single notification if they -occur within the same dispatch cycle. +FL6. Both changes mark the tree dirty; they coalesce into a single notification +if they occur within the same dispatch cycle. ### 6.3 Focused node removal -FL7. `useFocus()` installs middleware on the `remove` context API operation that -advances focus before a focused node is destroyed: +FL7. `useFocus` installs a synchronous `remove` interceptor that, when the node +being removed is focused, moves focus to its successor before teardown: ```ts - yield* FreedomApi.around({ - *remove([node], next) { - let focused = yield* node.eval(() => get("focused")); - if (focused === true) { - yield* FocusApi.operations.advance(); + node.scope.around(NodeApi, { + remove([target], next) { + if (target.props.focused === true) { + const successor = successorOf(target); + if (successor && successor !== target) focus(successor); } - yield* next(node); + return next(target); }, }); ``` -FL8. If the removed node is the only focusable node (i.e., it is the root), -`advance()` is a no-op (F7) and focus remains on root. This case should not -arise in practice because `remove` on the root is an error (C-rm3). +FL8. If the only focusable node is root, there is no successor and focus is left +unchanged. (Removing root is an error, C-rm3.) -FL9. Focus is advanced before `next(node)` is called, so the focus chain is -walked while the node is still in the tree. After `next(node)` completes, the -node's scope is destroyed and it leaves the focus chain naturally (its props, -including `focused`, are gone). +FL9. The successor is focused before `next(target)` tears the node down, so focus +never lands on a destroyed node. --- diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index 0387d00..049916c 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -199,7 +199,7 @@ interface Node { unset(key: string): void; createChild(name?: string): Node; sort(fn?: (a: Node, b: Node) => number): void; - remove(): void; + remove(): Promise; destroy(): Promise; } ``` @@ -368,7 +368,7 @@ interface Node { unset(key: string): void; createChild(name?: string): Node; sort(fn?: (a: Node, b: Node) => number): void; - remove(): void; + remove(): Promise; destroy(): Promise; } ``` @@ -390,7 +390,7 @@ node.update(key, fn: (prev: JsonValue | undefined) => JsonValue): void node.unset(key): void node.createChild(name?): Node node.sort(fn?: (a: Node, b: Node) => number): void -node.remove(): void +node.remove(): Promise node.destroy(): Promise ``` @@ -459,9 +459,10 @@ C20. `sort` marks the tree dirty (§8) (N19). **remove** -C-rm1. `remove()` detaches the node from its parent and tears down the node's -scope (and all descendant nodes) via `destroy()`. Detach is synchronous; -`destroy()` is the async scope dispose `remove` builds on. +C-rm1. `remove()` detaches the node from its parent (synchronously) and tears +down its scope and all descendants via `destroy()`. It **returns the teardown +`Promise`** (the result of `destroy()`); callers MAY await it or ignore it — the +teardown is initiated regardless. C-rm3. `remove()` on the root node is an error. From 4d8553ac6a0f8fc5bf0c84009ed68e6937f21bed Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 10:26:17 +0300 Subject: [PATCH 25/47] convert demo to sync api --- packages/demo/src/freedom-focus-text-input.ts | 294 +++++++----------- 1 file changed, 120 insertions(+), 174 deletions(-) diff --git a/packages/demo/src/freedom-focus-text-input.ts b/packages/demo/src/freedom-focus-text-input.ts index eee676c..6d5dcc3 100644 --- a/packages/demo/src/freedom-focus-text-input.ts +++ b/packages/demo/src/freedom-focus-text-input.ts @@ -1,22 +1,16 @@ -// oxlint-disable require-yield // oxlint-disable bombshell-dev/no-generic-error -import { each, ensure, main, type Operation, spawn, until } from "effection"; +import { each, ensure, main, spawn, until } from "effection"; import { createApi } from "effection/experimental"; import { advance, - append, createNodeData, + createRoot, current, DispatchApi, focusable, type Node, retreat, - set, - type Tree, - update, useFocus, - useNode, - useTree, } from "@bomb.sh/freedom"; import { alternateBuffer, @@ -25,10 +19,7 @@ import { cursor, fit, grow, - type KeyDown, type KeyEvent, - type KeyRepeat, - type KeyUp, type Op, open, percent, @@ -42,39 +33,14 @@ import { useStdin } from "./use-stdin.ts"; const GRAY = rgba(100, 100, 100); +// Synchronous input API. Core methods are no-ops; behavior is installed by +// interceptors on each node's scope and invoked at the focused node. const InputApi = createApi("demo:input", { - *keydown(event: KeyDown): Operation { - if (event.code === "Tab") { - yield* advance(); - } else if (event.code === "Backtab") { - yield* retreat(); - } - }, - *keyup(_event: KeyUp): Operation { - // no-op - }, - *keyrepeat(event: KeyRepeat): Operation { - if (event.code === "Tab") { - yield* advance(); - } else if (event.code === "Backtab") { - yield* retreat(); - } - }, + keydown(_event: KeyEvent): void {}, + keyup(_event: KeyEvent): void {}, + keyrepeat(_event: KeyEvent): void {}, }); -function onkeydown( - handler: ( - event: KeyDown, - next: (event: KeyDown) => Operation, - ) => Operation, -): Operation { - return InputApi.around({ - keydown([event], next) { - return handler(event, next); - }, - }); -} - interface LayoutOptions { node: Node; children: Iterable; @@ -85,26 +51,78 @@ const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>( () => [], ); -function* layout(body: (props: LayoutOptions) => Op[]): Operation { - const node = yield* useNode(); +function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { node.data.set(layoutKey, body); } -function* useTextInput(): Operation { - yield* focusable(); - yield* set("value", ""); - yield* onkeydown(function* (event, next) { - if (event.key.length === 1) { - yield* update("value", (v) => `${v ?? ""}${event.key}`); - } else if (event.code === "Backspace") { - yield* update("value", (v) => { - const str = String(v ?? ""); - return str.slice(0, -1); - }); - } else { - yield* next(event); - } +function makeTextInput(node: Node): void { + focusable(node); + node.set("value", ""); + layout(node, () => { + const color = node.props.focused ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.props.value ?? "")), + close(), + ]; }); + node.scope.around(InputApi, { + keydown([event], next) { + if (event.key.length === 1) { + node.update("value", (v) => `${v ?? ""}${event.key}`); + } else if (event.code === "Backspace") { + node.update("value", (v) => String(v ?? "").slice(0, -1)); + } else { + next(event); + } + }, + }); +} + +function screenBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + layout: { + height: grow(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + border: { + color: rgba(255, 255, 255), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + }), + ...children, + close(), + ]; +} + +function containerBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: 0xFFF, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + height: fit(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + ...children, + close(), + ]; } await main(function* () { @@ -112,122 +130,50 @@ await main(function* () { throw new Error("freedom demo requires an interactive TTY"); } - const tree = yield* useTree(function* () { - yield* useFocus(); - yield* layout(({ node, children }) => { - return [ - open(node.id, { - layout: { - height: grow(), - width: grow(), - direction: "ttb", - padding: { top: 1, right: 1, bottom: 1, left: 1 }, - }, - border: { - color: rgba(255, 255, 255), - top: 1, - right: 1, - bottom: 1, - left: 1, - }, - }), - ...children, - close(), - ]; - }); + const root = createRoot(); + useFocus(root.node); - yield* DispatchApi.around({ - *dispatch([event], next) { - if (isKeyboardEvent(event)) { - const focus = yield* current(); - const result = yield* focus.eval(function* () { - const handler = InputApi.operations[event.type]; - yield* handler(event as KeyDown & KeyUp & KeyRepeat); - }); - return result.ok ? { ok: true, value: true } : result; - } - return yield* next(event); - }, - }); + // Demux: route keyboard events to the focused node's input chain. + root.node.scope.around(DispatchApi, { + *dispatch([event], next) { + if (isKeyboardEvent(event)) { + InputApi.invoke(current(root.node).scope, event.type, [event]); + return { ok: true, value: true }; + } + return yield* next(event); + }, + }); - yield* append("input-1", function* () { - yield* layout(({ node, children }) => { - return [ - open(node.id, { - border: { color: 0xFFF, top: 1, right: 1, bottom: 1, left: 1 }, - layout: { - height: fit(), - width: grow(), - direction: "ttb", - padding: { top: 1, right: 1, bottom: 1, left: 1 }, - }, - }), - ...children, - close(), - ]; - }); + // Tab/Backtab navigation, bubbled up from inputs that don't consume the key. + root.node.scope.around(InputApi, { + keydown([event], next) { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(event); + } + }, + keyrepeat([event], next) { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(event); + } + }, + }); - yield* append("input-1-1", function* () { - yield* useTextInput(); - yield* layout(({ node }) => { - const color = node.props.focused ? rgba(255, 255, 255) : GRAY; - const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; - return [ - open(node.id, { - border, - layout: { - height: fit(3), - width: percent(0.3), - padding: { top: 1, right: 1, bottom: 1, left: 1 }, - }, - }), - text(String(node.props.value ?? "")), - close(), - ]; - }); - }); + layout(root.node, screenBody); - yield* append("input-1-2", function* () { - yield* useTextInput(); - yield* layout(({ node }) => { - const color = node.props.focused ? rgba(255, 255, 255) : GRAY; - const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; - return [ - open(node.id, { - border, - layout: { - height: fit(3), - width: percent(0.3), - padding: { top: 1, right: 1, bottom: 1, left: 1 }, - }, - }), - text(String(node.props.value ?? "")), - close(), - ]; - }); - }); - }); + const container = root.node.createChild("input-1"); + layout(container, containerBody); - yield* append("input-2", function* () { - yield* useTextInput(); - yield* layout(({ node }) => { - const color = node.props.focused ? rgba(255, 255, 255) : GRAY; - const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; - return [ - open(node.id, { - border, - layout: { - height: fit(3), - width: percent(0.3), - padding: { top: 1, right: 1, bottom: 1, left: 1 }, - }, - }), - text(String(node.props.value ?? "")), - close(), - ]; - }); - }); - }); + makeTextInput(container.createChild("input-1-1")); + makeTextInput(container.createChild("input-1-2")); + makeTextInput(root.node.createChild("input-2")); const { columns, rows } = stdout.isTTY ? { columns: stdout.columns, rows: stdout.rows } @@ -256,14 +202,14 @@ await main(function* () { })); } - tree.dispatch(event); + root.dispatch(event); yield* each.next(); } }); - function render(tree: Tree) { - const ops = walk(tree.root); + function render(): void { + const ops = walk(root.node); const { output } = term.render(ops); stdout.write(output); } @@ -273,10 +219,10 @@ await main(function* () { try { stdout.write(tty.apply); - render(tree); + render(); yield* spawn(function* () { - for (const _ of yield* each(tree)) { - render(tree); + for (const _ of yield* each(root)) { + render(); yield* each.next(); } }); From 55129a851c42eb166ba92df4ef75a24e94a0d9a4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 10:26:34 +0300 Subject: [PATCH 26/47] ignore agent-shell --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f8285e5..31b6f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ jspm_packages/ # Stores VSCode versions used for testing VSCode extensions .vscode-test +/.agent-shell/ From 7f95abcacdb461e05daea5fbf95fbaceea3e253c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 11:17:27 +0300 Subject: [PATCH 27/47] =?UTF-8?q?=E2=9C=A8=20createChild=20positional=20in?= =?UTF-8?q?sert=20via=20{=20before=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an options arg to createChild so a fresh child can be spliced before an existing sibling: createChild(name, { before }). Rebuilds the children Set to splice in position. Throws when before is not a current child. options is left open for future positioning hints (after, at). Spec: C14/C15, N7, N22. --- packages/freedom/specs/freedom-spec.md | 29 ++++++++++++++---- packages/freedom/src/lib/node.ts | 32 ++++++++++++++++---- packages/freedom/src/lib/types.ts | 6 +++- packages/freedom/test/freedom.test.ts | 41 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index 049916c..cd27acc 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -197,7 +197,7 @@ interface Node { set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; unset(key: string): void; - createChild(name?: string): Node; + createChild(name?: string, options?: { before?: Node }): Node; sort(fn?: (a: Node, b: Node) => number): void; remove(): Promise; destroy(): Promise; @@ -250,9 +250,11 @@ visible to mutations on this node and its descendants, via context inheritance. ### 5.4 Lifecycle -N7. A node is created by `parent.createChild(name?)`: the constructor creates the -child's scope (a child of the parent's), the child is attached to the parent's -children, and it is returned synchronously. +N7. A node is created by `parent.createChild(name?, options?)`: the constructor +creates the child's scope (a child of the parent's), the child is attached to the +parent's children, and it is returned synchronously. If `options.before` is +given, the child is inserted immediately before that sibling in insertion order +instead of being appended (C14). N8. A node is destroyed by `node.remove()` (detach + teardown) or directly by `node.destroy()`, which disposes the node's scope — halting all descendant node @@ -306,6 +308,11 @@ fresh because it runs against current property values at iteration time. N21. Installing or clearing a sort function via `sort()` MUST emit a notification (§8), because the iteration order of children may have changed. +N22. A child MAY be inserted at a specific position via +`createChild(name, { before })` (C14). This sets its position in **insertion +order**; it does not bypass an active sort function (N20). The `options` object +is the extension point for future positioning hints (e.g. `after`, `at`). + ### 5.7 Node Data Node data is typed, symbol-keyed storage for non-serializable, private values @@ -366,7 +373,7 @@ interface Node { set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; unset(key: string): void; - createChild(name?: string): Node; + createChild(name?: string, options?: { before?: Node }): Node; sort(fn?: (a: Node, b: Node) => number): void; remove(): Promise; destroy(): Promise; @@ -388,7 +395,7 @@ node.get(key): JsonValue | undefined node.set(key, value): void node.update(key, fn: (prev: JsonValue | undefined) => JsonValue): void node.unset(key): void -node.createChild(name?): Node +node.createChild(name?, options?: { before?: Node }): Node node.sort(fn?: (a: Node, b: Node) => number): void node.remove(): Promise node.destroy(): Promise @@ -448,6 +455,16 @@ C12. `createChild` attaches the child to this node's children and returns it C13. `createChild` marks the tree dirty (§8). +C14. `createChild(name?, options?)`: if `options.before` is provided, it MUST be a +current child of this node; the new child is inserted **immediately before** it in +insertion order. If `before` is not a current child, `createChild` throws. If +`before` is omitted, the child is appended. + +C15. The `before` position sets the child's place in **insertion order**. An +active sort function (N18) still reorders at read time, with insertion order as +the equal-compare tiebreaker (N20). `options` is an open object reserved for +future positioning hints (e.g. `after`, `at`). + **sort** C19. `sort(fn)` installs a sort function on the node. When `fn` is defined, diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index 4e8211f..5c6a894 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -7,7 +7,13 @@ import { type Scope, } from "effection"; import { createApi } from "effection/experimental"; -import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; +import type { + CreateChildOptions, + JsonValue, + Node, + NodeData, + NodeDataKey, +} from "./types.ts"; import { TreeContext } from "./state.ts"; import { validateJsonValue } from "./validate.ts"; @@ -95,8 +101,8 @@ export class NodeImpl implements Node { NodeApi.invoke(this.scope, "unset", [this, key]); } - createChild(name = ""): Node { - return NodeApi.invoke(this.scope, "createChild", [this, name]); + createChild(name = "", options?: CreateChildOptions): Node { + return NodeApi.invoke(this.scope, "createChild", [this, name, options]); } sort(fn?: (a: Node, b: Node) => number): void { @@ -139,10 +145,26 @@ export const NodeApi = createApi("freedom:node", { node.scope.expect(TreeContext).markDirty(); } }, - createChild(node: NodeImpl, name: string): Node { + createChild(node: NodeImpl, name: string, options?: CreateChildOptions): Node { const state = node.scope.expect(TreeContext); const child = new NodeImpl(state.nextId(), name, node); - node._children.add(child); + const before = options?.before; + if (before) { + if (!node._children.has(before as NodeImpl)) { + throw new Error("createChild: `before` is not a child of this node"); + } + // Set has no positional insert, so rebuild it with `child` spliced in. + const reordered = new Set(); + for (const existing of node._children) { + if (existing === before) { + reordered.add(child); + } + reordered.add(existing); + } + node._children = reordered; + } else { + node._children.add(child); + } state.nodes.set(child.id, child); state.markDirty(); return child; diff --git a/packages/freedom/src/lib/types.ts b/packages/freedom/src/lib/types.ts index 241eb8a..859e654 100644 --- a/packages/freedom/src/lib/types.ts +++ b/packages/freedom/src/lib/types.ts @@ -26,6 +26,10 @@ export interface NodeData { expect(key: NodeDataKey): T; } +export interface CreateChildOptions { + before?: Node; +} + export interface Node { readonly id: string; readonly name: string; @@ -38,7 +42,7 @@ export interface Node { set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; unset(key: string): void; - createChild(name?: string): Node; + createChild(name?: string, options?: CreateChildOptions): Node; sort(fn?: (a: Node, b: Node) => number): void; destroy(): Promise; remove(): Promise; diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 2bbe7eb..8d74ee4 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -108,6 +108,47 @@ describe("Children and ordering", () => { root.destroy(); }); + it("createChild inserts before a sibling", () => { + const root = createRoot(); + root.node.createChild("A"); + const c = root.node.createChild("C"); + root.node.createChild("B", { before: c }); + expect([...root.node.children].map((n) => n.name)).toEqual(["A", "B", "C"]); + root.destroy(); + }); + + it("createChild before the first child inserts at the front", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + root.node.createChild("Z", { before: a }); + expect([...root.node.children].map((n) => n.name)).toEqual(["Z", "A"]); + root.destroy(); + }); + + it("createChild throws when before is not a child", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + const stranger = root.node.createChild("B").createChild("nested"); + expect(() => a.createChild("x", { before: stranger })).toThrow(); + root.destroy(); + }); + + it("before sets insertion order under an active sort tiebreaker", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + const c = root.node.createChild("C"); + const b = root.node.createChild("B", { before: c }); + for (const n of [a, b, c]) { + n.set("priority", 1); + } + root.node.sort((x, y) => + (x.props["priority"] as number) - (y.props["priority"] as number) + ); + // all equal -> insertion order (with B spliced before C) is the tiebreaker + expect([...root.node.children].map((n) => n.name)).toEqual(["A", "B", "C"]); + root.destroy(); + }); + it("custom sort reorders children", () => { const root = createRoot(); const a = root.node.createChild("A"); From 3e0dea3de88d9379110316bb635dfebbb415b464 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 29 Jun 2026 19:34:25 +0300 Subject: [PATCH 28/47] add freedom-react with counter example --- packages/freedom-react/examples/counter.tsx | 247 +++++++++++ packages/freedom-react/examples/use-input.ts | 66 +++ packages/freedom-react/examples/use-stdin.ts | 37 ++ packages/freedom-react/package.json | 57 +++ packages/freedom-react/src/index.ts | 230 ++++++++++ packages/freedom-react/test/mount.test.ts | 120 ++++++ packages/freedom-react/test/suite.ts | 1 + packages/freedom-react/tsconfig.json | 6 + pnpm-lock.yaml | 427 ++++++++++++++++++- 9 files changed, 1172 insertions(+), 19 deletions(-) create mode 100644 packages/freedom-react/examples/counter.tsx create mode 100644 packages/freedom-react/examples/use-input.ts create mode 100644 packages/freedom-react/examples/use-stdin.ts create mode 100644 packages/freedom-react/package.json create mode 100644 packages/freedom-react/src/index.ts create mode 100644 packages/freedom-react/test/mount.test.ts create mode 100644 packages/freedom-react/test/suite.ts create mode 100644 packages/freedom-react/tsconfig.json diff --git a/packages/freedom-react/examples/counter.tsx b/packages/freedom-react/examples/counter.tsx new file mode 100644 index 0000000..0a85807 --- /dev/null +++ b/packages/freedom-react/examples/counter.tsx @@ -0,0 +1,247 @@ +// oxlint-disable bombshell-dev/no-generic-error +// A counter rendered through the freedom-react reconciler to @bomb.sh/tty. +// +// Run it: pnpm --filter @bomb.sh/freedom-react counter +// +// JSX (//