From a14c4d7678ac43ad4c649ce5575752d9dde058a7 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:49:55 -0500 Subject: [PATCH 1/9] chore: add proposal --- packages/interaction/PROPOSAL.md | 196 +++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 packages/interaction/PROPOSAL.md diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md new file mode 100644 index 00000000000..f9894292414 --- /dev/null +++ b/packages/interaction/PROPOSAL.md @@ -0,0 +1,196 @@ +This proposal has the following goals: + +- Avoid global registry for custom “interactions” +- Avoid `[interaction](event) {…}` syntax for interaction listening + +## 1. Introduce `Interaction` type + +Change `defineInteraction` to return a type-safe function, instead of a string. + +```ts +import { defineInteraction, on, type Interaction } from '@remix-run/interaction' + +// Assume `Press` and `PressEvent` are identical to what you see in ./src/lib/interactions/press.ts +const longPress = defineInteraction('rmx:long-press', Press) + +longPress satisfies Interaction // New return type +``` + +This change… + +- removes the need for `interface HTMLElementEventMap {…}` extensions +- removes the need for a global runtime registry for custom interactions + +#### Usage + +An example of using an interaction with a JSX element: + +```tsx +return ( + +) +``` + +The `longPress()` interaction returns a type-safe event descriptor: + +```ts +longPress(…) satisfies { + 'rmx:long-press': (event: PressEvent) => void +} +``` + +If the `...` spread syntax feels jarring to you, note that you can nest it in an array instead. Before you roll your eyes, note that the new `on()` function (described in the next section) is yet another alternative syntax that you might prefer. The key here is to be flexible, as it lets developers choose the syntax that feels most natural to them, and it's more forgiving to agentic coding. + +```tsx + +``` + +## 2. Advanced `on()` function + +Repurpose the `on()` function to be a type-safe event descriptor factory. + +```ts +const result = on(button, { + click(event) { + event.type satisfies 'click' + event.currentTarget satisfies HTMLButtonElement + }, + focusin: capture(event => {…}), +}) + +result satisfies { + target: HTMLButtonElement, + events: { + click: (event: MouseEvent) => void + focusin: { + capture: true, + listener: (event: FocusEvent) => void, + } + } +} +``` + +It's also wrapped with `new Proxy()` for convenient declaration syntax: + +```ts +const result = on.click(button, { once: true }, (event) => { + event.type satisfies 'click' + event.currentTarget satisfies HTMLButtonElement +}) + +result satisfies { + target: HTMLButtonElement + events: { + click: { + once: true + listener: (event: MouseEvent) => void + } + } +} +``` + +### Inferring the event target + +In many cases, the event target can be inferred, so passing an event target as the first argument is optional. This is most beneficial for the new JSX `events` prop (renamed from `on`). + +```tsx +import { events } from '@remix-run/interaction' + +function MyButton(this: Remix.Handle) { + return ( + + ) +} +``` + +**Importantly**, you can still pass a listeners object to the `events` prop. This API will feel more natural to beginners. + +```tsx + +``` + +Now, this complicates the type definition of the `events` prop. But **forwarding** a component's `events` prop to a child JSX element is easy if we add nesting support. + +```tsx +function Foo(props: { + events?: Remix.EventsProp +}) { + return ( + + ) +} +``` + +## 3. Renamed functions + +The current `on()` function should be renamed to `events()`. + +It should support the same values as the new JSX `events` prop. + +```ts +// Basic API +events(target, signal, { + foo(event) {…}, + bar: capture(event => {…}), +}) + +// Advanced API +events(target, signal, [ + on.foo(event => {…}), + on.bar({ capture: true }, event => {…}), + { + foo(event) {…}, + bar: capture(event => {…}), + } +]) +``` + +Also, `events()` can support multiple targets, thanks to the `on()` function. + +```ts +events(signal, [ + on.click(button1, event => {…}), + on.click(button2, event => {…}), +]) +``` From 5ec7d9297326c70600c7b407879fa7311cdce732 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:04:52 -0500 Subject: [PATCH 2/9] chore: tweak intro --- packages/interaction/PROPOSAL.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index f9894292414..a29b0b68b2c 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -2,6 +2,9 @@ This proposal has the following goals: - Avoid global registry for custom “interactions” - Avoid `[interaction](event) {…}` syntax for interaction listening +- Restore support for “event descriptor factories” (e.g. `on.click(fn)`) while keeping the new string-keyed API (e.g. `{ click: fn }`) + +I believe these goals reflect the main concerns people have about the latest Events API (`@remix-run/interaction@0.1.0`). ## 1. Introduce `Interaction` type From 0e7bac081257220e972eefcce27e291b456deac2 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:10:45 -0500 Subject: [PATCH 3/9] chore: fix imports --- packages/interaction/PROPOSAL.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index a29b0b68b2c..1007acbcc1c 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -65,6 +65,8 @@ If the `...` spread syntax feels jarring to you, note that you can nest it in an Repurpose the `on()` function to be a type-safe event descriptor factory. ```ts +import { on } from '@remix-run/interaction' + const result = on(button, { click(event) { event.type satisfies 'click' @@ -109,7 +111,8 @@ result satisfies { In many cases, the event target can be inferred, so passing an event target as the first argument is optional. This is most beneficial for the new JSX `events` prop (renamed from `on`). ```tsx -import { events } from '@remix-run/interaction' +import { on } from '@remix-run/interaction' +import { longPress } from '@remix-run/interaction/press' function MyButton(this: Remix.Handle) { return ( @@ -172,6 +175,8 @@ The current `on()` function should be renamed to `events()`. It should support the same values as the new JSX `events` prop. ```ts +import { events, on } from '@remix-run/interaction' + // Basic API events(target, signal, { foo(event) {…}, From 19154dd9657c27de8541af6cb0d1867e723b6465 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:11:43 -0500 Subject: [PATCH 4/9] chore: fix more imports --- packages/interaction/PROPOSAL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index 1007acbcc1c..f34eb35396f 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -65,7 +65,7 @@ If the `...` spread syntax feels jarring to you, note that you can nest it in an Repurpose the `on()` function to be a type-safe event descriptor factory. ```ts -import { on } from '@remix-run/interaction' +import { on, capture } from '@remix-run/interaction' const result = on(button, { click(event) { @@ -175,7 +175,7 @@ The current `on()` function should be renamed to `events()`. It should support the same values as the new JSX `events` prop. ```ts -import { events, on } from '@remix-run/interaction' +import { events, on, capture } from '@remix-run/interaction' // Basic API events(target, signal, { From 182baa00731ade79a725e8f55fa60eb80f9c93b5 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:35:23 -0500 Subject: [PATCH 5/9] chore: remove unused import --- packages/interaction/PROPOSAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index f34eb35396f..8b0a9d63a2d 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -11,7 +11,7 @@ I believe these goals reflect the main concerns people have about the latest Eve Change `defineInteraction` to return a type-safe function, instead of a string. ```ts -import { defineInteraction, on, type Interaction } from '@remix-run/interaction' +import { defineInteraction, type Interaction } from '@remix-run/interaction' // Assume `Press` and `PressEvent` are identical to what you see in ./src/lib/interactions/press.ts const longPress = defineInteraction('rmx:long-press', Press) From cbc275c68678af33655188eac15402c486e32273 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:16:17 -0500 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20mention=20potential=20for=20`on:?= =?UTF-8?q?=20[=E2=80=A6]`=20API=20in=20JSX=20`events`=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/interaction/PROPOSAL.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index 8b0a9d63a2d..285c3f2bc08 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -60,6 +60,21 @@ If the `...` spread syntax feels jarring to you, note that you can nest it in an >Click me ``` +**Alternatively**, people might appreciate a special `on` property for such scenarios: + +```tsx + +``` + +I don't have an opinion on which is better. + ## 2. Advanced `on()` function Repurpose the `on()` function to be a type-safe event descriptor factory. From 6b10c0cc870ed62f6a293f4bb203420cfdc7d4c5 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:33:26 -0500 Subject: [PATCH 7/9] chore: mention prop forwarding further up --- packages/interaction/PROPOSAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index 285c3f2bc08..27a1b2d6439 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -73,7 +73,7 @@ If the `...` spread syntax feels jarring to you, note that you can nest it in an >Click me ``` -I don't have an opinion on which is better. +I don't have an opinion on which is “better”, but the `events={[ … ]}` array syntax is **ideal** for prop forwarding (as described in the “Inferring the event target” section). ## 2. Advanced `on()` function From 494dbcd76b2c65fffa353912aacbff22210396eb Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:39:45 -0500 Subject: [PATCH 8/9] chore: clarify why satisfies is used --- packages/interaction/PROPOSAL.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index 27a1b2d6439..d111c3742dd 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -24,6 +24,9 @@ This change… - removes the need for `interface HTMLElementEventMap {…}` extensions - removes the need for a global runtime registry for custom interactions +> [!NOTE] +> Use of `satisfies` in this proposal is purely illustrative. You won't need it when using these APIs in your code. Read it as "this variable ABC is inferred to be of type XYZ". + #### Usage An example of using an interaction with a JSX element: From 1ecf7036d6d3191a0c6ab2b575c1d4bc1d1b09d0 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:25:52 -0500 Subject: [PATCH 9/9] chore: v2 --- packages/interaction/PROPOSAL.md | 115 ++++++++----------------------- 1 file changed, 28 insertions(+), 87 deletions(-) diff --git a/packages/interaction/PROPOSAL.md b/packages/interaction/PROPOSAL.md index d111c3742dd..9f7e16e8eeb 100644 --- a/packages/interaction/PROPOSAL.md +++ b/packages/interaction/PROPOSAL.md @@ -34,7 +34,7 @@ An example of using an interaction with a JSX element: ```tsx return ( ``` -**Alternatively**, people might appreciate a special `on` property for such scenarios: +## 2. Make `on()` multi-purpose -```tsx - -``` - -I don't have an opinion on which is “better”, but the `events={[ … ]}` array syntax is **ideal** for prop forwarding (as described in the “Inferring the event target” section). - -## 2. Advanced `on()` function - -Repurpose the `on()` function to be a type-safe event descriptor factory. - -```ts -import { on, capture } from '@remix-run/interaction' +The `on()` function can be used 1 of 2 ways: +- Add one or more listeners to an event target +- Declare an event descriptor (when no event target is provided) -const result = on(button, { - click(event) { - event.type satisfies 'click' - event.currentTarget satisfies HTMLButtonElement - }, - focusin: capture(event => {…}), -}) - -result satisfies { - target: HTMLButtonElement, - events: { - click: (event: MouseEvent) => void - focusin: { - capture: true, - listener: (event: FocusEvent) => void, - } - } -} -``` - -It's also wrapped with `new Proxy()` for convenient declaration syntax: - -```ts -const result = on.click(button, { once: true }, (event) => { - event.type satisfies 'click' - event.currentTarget satisfies HTMLButtonElement -}) - -result satisfies { - target: HTMLButtonElement - events: { - click: { - once: true - listener: (event: MouseEvent) => void - } - } -} -``` - -### Inferring the event target - -In many cases, the event target can be inferred, so passing an event target as the first argument is optional. This is most beneficial for the new JSX `events` prop (renamed from `on`). +When declaring event listeners with JSX, you don't provide an event target: ```tsx import { on } from '@remix-run/interaction' @@ -135,7 +78,7 @@ import { longPress } from '@remix-run/interaction/press' function MyButton(this: Remix.Handle) { return ( ``` -Now, this complicates the type definition of the `events` prop. But **forwarding** a component's `events` prop to a child JSX element is easy if we add nesting support. +### Forwarding the `on` prop + +Your components may want to accept an `on` prop and forward it to a child JSX element. This is easy if we add nesting support. Essentially, the reconciler will flatten the array of listeners into a single object. ```tsx function Foo(props: { - events?: Remix.EventsProp + on?: Remix.EventListeners }) { return ( -