Skip to content

Commit 851a6c8

Browse files
authored
Add handler closure transformation infrastructure (#2001)
Add handler closure transformation
1 parent 35bb3bb commit 851a6c8

31 files changed

+1680
-317
lines changed

packages/html/src/jsx.d.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,8 +1763,8 @@ declare namespace CTDOM {
17631763

17641764
export interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
17651765
// CT extensions
1766-
"onClick"?: CellLike<HandlerEvent<unknown>>;
1767-
"onChange"?: CellLike<HandlerEvent<unknown>>;
1766+
"onClick"?: EventHandler<unknown>;
1767+
"onChange"?: EventHandler<unknown>;
17681768
"children"?: RenderNode | undefined;
17691769
// Allow React-isms
17701770
"key"?: number;
@@ -2825,10 +2825,12 @@ interface CTThemeDef {
28252825

28262826
type CTThemeInput = Partial<CTThemeDef> & Record<string, unknown>;
28272827

2828-
type HandlerEvent<T> = {
2828+
type CTEvent<T> = {
28292829
detail: T;
28302830
};
28312831

2832+
type EventHandler<T> = CellLike<CTEvent<T>> | ((event?: CTEvent<T>) => void);
2833+
28322834
// `Charm` is not a recipe type.
28332835
type Charm = any;
28342836

@@ -2889,16 +2891,16 @@ interface CTPlaidLinkElement extends CTHTMLElement {}
28892891

28902892
interface CTDraggableAttributes<T> extends CTHTMLAttributes<T> {
28912893
"key"?: number;
2892-
"x"?: CellLike<HandlerEvent<any>>;
2893-
"y"?: CellLike<HandlerEvent<any>>;
2894+
"x"?: EventHandler<any>;
2895+
"y"?: EventHandler<any>;
28942896
"hidden"?: Booleanish;
2895-
"onpositionchange"?: CellLike<HandlerEvent<any>>;
2897+
"onpositionchange"?: EventHandler<any>;
28962898
}
28972899

28982900
interface CTCanvasAttributes<T> extends CTHTMLAttributes<T> {
28992901
"width"?: string | number;
29002902
"height"?: string | number;
2901-
"onct-canvas-click"?: CellLike<HandlerEvent<any>>;
2903+
"onct-canvas-click"?: EventHandler<any>;
29022904
}
29032905

29042906
interface CTPlaidLinkAttributes<T> extends CTHTMLAttributes<T> {
@@ -2944,7 +2946,7 @@ interface CTAttachmentsBarAttributes<T> extends CTHTMLAttributes<T> {
29442946

29452947
interface CTTagsAttributes<T> extends CTHTMLAttributes<T> {
29462948
"tags"?: string[];
2947-
"onct-change"?: CellLike<HandlerEvent<any>>;
2949+
"onct-change"?: EventHandler<any>;
29482950
}
29492951

29502952
interface CTToolbarAttributes<T> extends CTHTMLAttributes<T> {
@@ -3017,7 +3019,7 @@ interface CTSendMessageAttributes<T> extends CTHTMLAttributes<T> {
30173019
"value"?: any;
30183020
"placeholder"?: string;
30193021
"appearance"?: "rounded";
3020-
"onmessagesend"?: CellLike<HandlerEvent<{ message: string }>>;
3022+
"onmessagesend"?: EventHandler<{ message: string }>;
30213023
"inline"?: Booleanish;
30223024
}
30233025

@@ -3031,7 +3033,7 @@ interface CTScrollAttributes<T> extends CTHTMLAttributes<T> {
30313033
interface CTOutlinerAttributes<T> extends CTHTMLAttributes<T> {
30323034
"$value": CellLike<{ root: OutlinerNode }>;
30333035
"$mentionable"?: CellLike<Charm[]>;
3034-
"oncharm-link-click"?: CellLike<HandlerEvent<{ charm: Cell<Charm> }>>;
3036+
"oncharm-link-click"?: EventHandler<{ charm: Cell<Charm> }>;
30353037
}
30363038

30373039
interface CTChatMessageAttributes<T> extends CTHTMLAttributes<T> {
@@ -3075,7 +3077,7 @@ interface CTListAttributes<T> extends CTHTMLAttributes<T> {
30753077
/** setting this hides the 'add item' form built into the list */
30763078
"readonly"?: boolean;
30773079
"title"?: string;
3078-
"onct-remove-item"?: CellLike<HandlerEvent<{ item: CtListItem }>>;
3080+
"onct-remove-item"?: EventHandler<{ item: CtListItem }>;
30793081
}
30803082

30813083
interface CTListItemAttributes<T> extends CTHTMLAttributes<T> {
@@ -3150,17 +3152,15 @@ interface CTCheckboxAttributes<T> extends CTHTMLAttributes<T> {
31503152
"indeterminate"?: boolean;
31513153
"name"?: string;
31523154
"value"?: string;
3153-
"onct-change"?: CellLike<HandlerEvent<any>>;
3155+
"onct-change"?: EventHandler<any>;
31543156
}
31553157

31563158
interface CTSelectAttributes<T> extends CTHTMLAttributes<T> {
31573159
"$value": CellLike<any | any[]>;
31583160
"items": { label: string; value: any }[];
31593161
"multiple"?: boolean;
3160-
"onct-change"?: CellLike<
3161-
HandlerEvent<
3162-
{ items: { label: string; value: any }[]; value: any | any[] }
3163-
>
3162+
"onct-change"?: EventHandler<
3163+
{ items: { label: string; value: any }[]; value: any | any[] }
31643164
>;
31653165
}
31663166

packages/schema-generator/src/schema-generator.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,9 @@ export class SchemaGenerator implements ISchemaGenerator {
612612
return { type: "boolean" };
613613
case ts.SyntaxKind.NullKeyword:
614614
return { type: "null" };
615+
case ts.SyntaxKind.NeverKeyword:
616+
// Reject all values (never type can never occur)
617+
return false as SchemaDefinition;
615618
case ts.SyntaxKind.UndefinedKeyword:
616619
case ts.SyntaxKind.VoidKeyword:
617620
case ts.SyntaxKind.AnyKeyword:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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+
```

packages/ts-transformers/src/ast/dataflow.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getExpressionText,
55
getMemberSymbol,
66
isFunctionParameter,
7+
isMethodCall,
78
} from "./utils.ts";
89
import { symbolDeclaresCommonToolsDefault } from "../core/mod.ts";
910
import { isOpaqueRefType } from "../transformers/opaque-ref/opaque-ref.ts";
@@ -428,11 +429,7 @@ export function createDataFlowAnalyzer(
428429

429430
// Don't capture property accesses that are method calls.
430431
// For example, `element.trim` in `element.trim()` should not be captured.
431-
if (
432-
expression.parent &&
433-
ts.isCallExpression(expression.parent) &&
434-
expression.parent.expression === expression
435-
) {
432+
if (isMethodCall(expression)) {
436433
// This is a method call like element.trim() - don't capture it
437434
return merged;
438435
}

packages/ts-transformers/src/ast/mod.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export {
88
export {
99
getExpressionText,
1010
getMemberSymbol,
11+
getMethodCallTarget,
1112
isFunctionParameter,
13+
isMethodCall,
1214
visitEachChildWithJsx,
1315
} from "./utils.ts";
1416
export {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import ts from "typescript";
2+
3+
/**
4+
* Check if a declaration is at module scope (top-level of source file).
5+
*/
6+
export function isModuleScopedDeclaration(decl: ts.Declaration): boolean {
7+
// Walk up to find the parent
8+
let parent = decl.parent;
9+
10+
// For variable declarations, need to go up through VariableDeclarationList
11+
if (ts.isVariableDeclaration(decl)) {
12+
// VariableDeclaration -> VariableDeclarationList -> VariableStatement -> SourceFile
13+
parent = parent?.parent?.parent;
14+
}
15+
// For function declarations, parent is already SourceFile (if module-scoped)
16+
// No need to reassign
17+
18+
return parent ? ts.isSourceFile(parent) : false;
19+
}
20+
21+
/**
22+
* Check if a declaration represents a function (we can't serialize functions).
23+
* NOTE: Currently treats all CallExpressions as functions, which may be overly broad.
24+
* Consider refining to only match handler(), lift(), recipe() calls.
25+
*/
26+
export function isFunctionDeclaration(decl: ts.Declaration): boolean {
27+
// Direct function declarations
28+
if (ts.isFunctionDeclaration(decl)) {
29+
return true;
30+
}
31+
32+
// Arrow functions or function expressions assigned to variables
33+
if (ts.isVariableDeclaration(decl) && decl.initializer) {
34+
const init = decl.initializer;
35+
if (
36+
ts.isArrowFunction(init) ||
37+
ts.isFunctionExpression(init) ||
38+
ts.isCallExpression(init) // Includes handler(), lift(), etc.
39+
) {
40+
return true;
41+
}
42+
}
43+
44+
return false;
45+
}
46+
47+
/**
48+
* Check if a declaration is within a specific function's scope using node identity.
49+
* @param decl - The declaration to check
50+
* @param func - The function to check against
51+
* @returns true if decl is within func's scope (but stops at nested function boundaries)
52+
*/
53+
export function isDeclaredWithinFunction(
54+
decl: ts.Declaration,
55+
func: ts.FunctionLikeDeclaration,
56+
): boolean {
57+
// Walk up the tree from the declaration
58+
let current: ts.Node | undefined = decl;
59+
while (current) {
60+
// Found our callback function
61+
if (current === func) {
62+
return true;
63+
}
64+
65+
// Stop at function boundaries (don't cross into nested functions)
66+
if (current !== decl && ts.isFunctionLike(current)) {
67+
return false;
68+
}
69+
70+
current = current.parent;
71+
}
72+
73+
return false;
74+
}
75+
76+
/**
77+
* Check if a declaration is within any function-like node.
78+
* Useful for determining if a variable is local vs closure-captured.
79+
*/
80+
export function isDeclaredInFunctionScope(
81+
decl: ts.Declaration,
82+
): ts.SignatureDeclaration | undefined {
83+
let current: ts.Node | undefined = decl;
84+
while (current) {
85+
if (ts.isFunctionLike(current)) {
86+
return current;
87+
}
88+
current = current.parent;
89+
}
90+
return undefined;
91+
}

0 commit comments

Comments
 (0)