|
| 1 | +# Handler Closures Transformation Spec |
| 2 | + |
| 3 | +## Background |
| 4 | + |
| 5 | +- The `dx1/unify-types` branch introduces a unified cell/stream type system |
| 6 | + built on the new `CELL_BRAND` symbol and capability interfaces (`IReadable`, |
| 7 | + `IWritable`, `IKeyable`, etc.). |
| 8 | +- API exports and `commontools.d.ts` now define `HandlerFunction` in terms of |
| 9 | + `HandlerState<T>` and `StripCell<T>`, and the runner augments these interfaces |
| 10 | + with runtime-specific behaviour. |
| 11 | +- Schema generator and opaque-ref transformers detect cell kinds via |
| 12 | + `CELL_BRAND`, keeping the hierarchical capture utilities valid across |
| 13 | + transformers. |
| 14 | + |
| 15 | +## Impact on Handler Closures |
| 16 | + |
| 17 | +- Generated handler callbacks must follow the public signature `(event, props)` |
| 18 | + so that `HandlerState<T>` inference works and schema injection can attach |
| 19 | + `toSchema` calls automatically. |
| 20 | +- Captured values should appear under the `params` object we pass to |
| 21 | + `handler(...)`; we can reuse the hierarchical capture tree helpers we built |
| 22 | + for map closures to keep names/structure intact. |
| 23 | +- Inline closures should only be rewritten when they are literal functions |
| 24 | + supplied to `on*` JSX attributes and are not already wrapped in |
| 25 | + `handler(...)`. |
| 26 | + |
| 27 | +## Behaviour Overview |
| 28 | + |
| 29 | +- Detect eligible JSX attributes using the existing `isEventHandlerJsxAttribute` |
| 30 | + helper (prop name starts with `on`). |
| 31 | +- For inline arrow functions transform |
| 32 | + ```tsx |
| 33 | + <button onClick={() => counter.set(counter.get() + 1)} type="button" />; |
| 34 | + ``` |
| 35 | + into |
| 36 | + ```tsx |
| 37 | + <button |
| 38 | + onClick={handler((event, { counter }) => counter.set(counter.get() + 1))({ |
| 39 | + counter, |
| 40 | + })} |
| 41 | + type="button" |
| 42 | + />; |
| 43 | + ``` |
| 44 | + preserving user-supplied parameter patterns whenever present. |
| 45 | +- The params object mirrors the capture tree: nested state (`state.user.name`) |
| 46 | + becomes `{ state: { user: { name: state.user.name } } }` and optional |
| 47 | + chaining/computed keys are left untouched. |
| 48 | +- Inline handlers are rewritten even when no captures exist. This keeps JSX |
| 49 | + syntax uniform and avoids downstream behaviour changes when captures are |
| 50 | + introduced later. |
| 51 | +- Schema injection already recognises `handler(...)` calls and inserts |
| 52 | + `toSchema` arguments, so no additional transformer work is required there. |
| 53 | + |
| 54 | +## Implementation Summary |
| 55 | + |
| 56 | +1. Extend `ClosureTransformer` with a visitor that: |
| 57 | + - Finds JSX attributes whose name starts with `on`. |
| 58 | + - Confirms the initializer is an inline arrow function literal (excluding |
| 59 | + existing `handler(...)` invocations or other non-inline references). |
| 60 | + - Reuses `collectCaptures` / `groupCapturesByRoot` to build the capture tree |
| 61 | + for the handler body. |
| 62 | +2. Build the rewritten callback: |
| 63 | + - Preserve explicit event/state parameters if the user provided them; |
| 64 | + otherwise synthesise `(eventParam, paramsParam)` placeholders. |
| 65 | + - Alias runtime names to originals via destructuring (e.g. |
| 66 | + `({ params: { counter } })`). |
| 67 | + - Leave the function body untouched except for computed-key caching |
| 68 | + (identical to map closures). |
| 69 | +3. Emit the `handler(...)` call and params object; rely on schema injection to |
| 70 | + add `toSchema` arguments. |
| 71 | +4. Capture regression coverage for: a basic increment handler; optional |
| 72 | + chaining/captured state; computed property access (`list[nextKey()]`); and an |
| 73 | + outer-variable collision (`const counter = …; onClick={() => counter}`). |
| 74 | +5. Run `deno lint` and `deno task test` to cover the closure, derive, and |
| 75 | + handler suites. |
| 76 | + |
| 77 | +## Decisions and Constraints |
| 78 | + |
| 79 | +- We focus on inline arrow functions for now; other inline forms can be handled |
| 80 | + later if necessary. |
| 81 | +- If the attribute already references a value returned from `handler(...)` (an |
| 82 | + `OpaqueRef`/cell) or any non-inline identifier, we leave it as-is. |
| 83 | +- Captured cells/streams/opaque refs require no special casing—the capture tree |
| 84 | + handles them generically. |
| 85 | + |
| 86 | +## Examples |
| 87 | + |
| 88 | +### Basic Capture |
| 89 | + |
| 90 | +```tsx |
| 91 | +// Before |
| 92 | +<button onClick={() => state.counter.set(state.counter.get() + 1)} type="button" /> |
| 93 | + |
| 94 | +// After |
| 95 | +<button |
| 96 | + type="button" |
| 97 | + onClick={handler((event, { counter }) => counter.set(counter.get() + 1))({ |
| 98 | + counter, |
| 99 | + })} |
| 100 | +/> |
| 101 | +``` |
| 102 | + |
| 103 | +### Optional Chaining and Computed Key |
| 104 | + |
| 105 | +```tsx |
| 106 | +// Before |
| 107 | +<button |
| 108 | + type="button" |
| 109 | + onClick={() => recordMap[nextKey()]?.set(state.metrics.get() ?? 0)} |
| 110 | +/> |
| 111 | + |
| 112 | +// After |
| 113 | +<button |
| 114 | + type="button" |
| 115 | + onClick={handler((event, { recordMap, state }) => |
| 116 | + recordMap[nextKey()]?.set(state.metrics.get() ?? 0) |
| 117 | + )({ |
| 118 | + recordMap, |
| 119 | + state: { |
| 120 | + metrics: state.metrics, |
| 121 | + }, |
| 122 | + })} |
| 123 | +/> |
| 124 | +``` |
0 commit comments