feat: emit behavior events from editor.on#2764
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: a8a1787 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📦 Bundle Stats —
|
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 777.7 KB | +40 B, +0.0% |
| Internal (gzip) | 147.8 KB | -11 B, -0.0% |
| Bundled (raw) | 1.38 MB | +40 B, +0.0% |
| Bundled (gzip) | 309.7 KB | -13 B, -0.0% |
| Import time | 98ms | +1ms, +0.6% |
@portabletext/editor/behaviors
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 467 B | - |
| Internal (gzip) | 207 B | - |
| Bundled (raw) | 424 B | - |
| Bundled (gzip) | 171 B | - |
| Import time | 2ms | -0ms, -1.5% |
@portabletext/editor/plugins
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 2.7 KB | - |
| Internal (gzip) | 894 B | - |
| Bundled (raw) | 2.5 KB | - |
| Bundled (gzip) | 827 B | - |
| Import time | 7ms | +0ms, +0.7% |
@portabletext/editor/selectors
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 79.3 KB | - |
| Internal (gzip) | 14.5 KB | - |
| Bundled (raw) | 74.8 KB | - |
| Bundled (gzip) | 13.4 KB | - |
| Import time | 8ms | -0ms, -0.6% |
@portabletext/editor/traversal
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 25.4 KB | - |
| Internal (gzip) | 5.0 KB | - |
| Bundled (raw) | 25.4 KB | - |
| Bundled (gzip) | 5.0 KB | - |
| Import time | 6ms | +0ms, +0.5% |
@portabletext/editor/utils
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 28.7 KB | - |
| Internal (gzip) | 6.0 KB | - |
| Bundled (raw) | 26.7 KB | - |
| Bundled (gzip) | 5.7 KB | - |
| Import time | 6ms | -0ms, -1.0% |
🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
📦 Bundle Stats — @portabletext/markdown
Compared against main (a43a0fc6)
| Metric | Value | vs main (a43a0fc) |
|---|---|---|
| Internal (raw) | 53.0 KB | - |
| Internal (gzip) | 9.6 KB | - |
| Bundled (raw) | 347.6 KB | - |
| Bundled (gzip) | 96.0 KB | - |
| Import time | 38ms | -3ms, -6.5% |
🗺️ View treemap · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
Subscribe to behavior events flowing through the chain by name:
editor.on('drag.dragover', (event) => { ... })
editor.on('insert.text', (event) => { ... })
`editor.on('*', listener)` and `EventListenerPlugin` see them
alongside the existing emitted events.
The tap fires once per behavior event, before the chain runs.
Observers see user intent regardless of whether a high-priority
handler decides to swallow it.
ee668de to
a8a1787
Compare
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
…tor/plugins
Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:
- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
Used inside renderBlock or container render callbacks to know where to
paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
paint it themselves while keeping the engine's visual identity.
- DropPosition — type.
The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).
The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
| case 'value changed': | ||
| listener(event) | ||
| break | ||
| default: | ||
| // Behavior events (`insert`, `insert.text`, `drag.dragover`, |
There was a problem hiding this comment.
This new default: doing listener(event) matches every named case above it that also does listener(event); break. The whole switch can collapse to a single listener(event) call (drop the wrapping switch (event.type) { ... }). The named cases were a guard back when the relay's union didn't include behavior events; with BehaviorEvent now in EditorEmittedEvent they're noise.
Optional: keep the switch with default: listener(event) only, drop the 12 case branches.
| case 'patches': | ||
| // PatchesEvent is an editor-machine internal event; not a behavior | ||
| // event and not forwarded to the relay. | ||
| break | ||
| default: | ||
| // Behavior events are emitted from the editor machine's | ||
| // `handle behavior event` action before the chain runs. Forward to | ||
| // the relay so `editor.on(behaviorEventType, ...)` fires. | ||
| config.relayActor.send(event as EditorEmittedEvent) | ||
| } | ||
| }) |
There was a problem hiding this comment.
The comment frames this as "PatchesEvent is internal" but the load-bearing reason we filter is that the relay's EditorEmittedEvent doesn't include PatchesEvent — sending it would be a type error at runtime. Worth rewording:
case 'patches':
// The relay's EditorEmittedEvent doesn't include PatchesEvent — drop it.
breakSame for the default-case comment two lines below: the examples list (insert, insert.text, drag.dragover, serialize) is filler. The type union is the documentation. Could shrink to one line.
| const behaviors = [...context.behaviors.values()].map( | ||
| (config) => config.behavior, | ||
| ) | ||
| enqueue.emit(event.behaviorEvent) |
There was a problem hiding this comment.
enqueue.emit(event.behaviorEvent) runs before the try/catch around performEvent. That's the right semantic (observe intent regardless of swallowing — also captured in the PR body), but the placement is load-bearing and worth pinning with a one-liner so a future move-into-the-try doesn't quietly flip the semantic:
// Emit before performEvent so subscribers see the event even if the chain throws or swallows it.
enqueue.emit(event.behaviorEvent)| describe('editor.on behavior event', () => { | ||
| test('editor.on fires per behavior event by name', async () => { | ||
| const onInsertText = vi.fn() | ||
| const keyGenerator = createTestKeyGenerator() |
There was a problem hiding this comment.
Three test-quality nits per /behavior conventions:
expect(observed).toContain('tap:?')on line 81 weakens the assertion. Should be deep equality on the full array:
expect(observed).toEqual(['tap:?'])-
Tests 1, 2, and 3 share the same
createTestEditorsetup withinitialValueand the sameselectevent. Worth hoisting asetup()helper or sharing fixtures so the test bodies surface only what differs (theeditor.onsubscription + the send + the assertion). -
vi.waitForpolls until the assertion passes.enqueue.emitand the relay forward synchronously, so onceeditor.sendreturns the event has already been emitted — these can assert synchronously withoutwaitFor. Tighter test, faster execution, and surfaces async surprises if they ever appear.
Today
editor.onexposes a fixed set of events (patch,mutation,selection, plus lifecycle). The behavior events that flow through the chain —insert.text,drag.dragover,serialize,select, etc. — have no public subscription surface. A plugin author who wants to derive state from "the user is dragging over this position" can't do it cleanly: either they reach into engine internals, or they request a newRenderPropsfield.This PR widens
EditorEmittedEventto includeBehaviorEvent. Behavior events are emitted from the editor machine and forwarded through the relay, so consumers subscribe by name:editor.on(type, listener)is typed-narrowed via xstate's existing channel overload, so call sites get the right event shape withoutevent.type === '...'guards.EventListenerPluginsees behavior events too.Pre- vs post-handler tap
The tap fires once per behavior event, before the behavior chain runs. Observers see user intent regardless of whether a high-priority handler decides to swallow the event.
Post-handler would miss intercepted events, which is the wrong default for plugin authors building providers. Example: a
dropPositionprovider needs to track the latestdrag.dragovereven whenbehavior.core.dndends up swallowing the dragover to set its own internal state — the provider's view of "where the user is dragging" shouldn't depend on which handler wins.A consequence of this placement:
raise(event)calls inside an action body do not re-enter thehandle behavior eventaction, so they're invisible toeditor.on. Only externally-dispatched andsendBack-routed behavior events reach subscribers. That preserves the "observe intent" semantic (subscribers see the events users sent, not decomposition machinery).Removed: deprecated
'unset'emitted eventThe deprecated
'unset'emitted event collided with theBehaviorEventvariant of the same name and is removed. It was@deprecatedin favor of'patch'. No consumers in this repo's downstream apps (sanity-io/sanity,sanity-io/canvas) subscribe to it.Downstream impact
Studio (
sanity-io/sanity): ThreeEventListenerPluginconsumers —PortableTextInput,StringInputPortableText,CommentInput. All use exhaustiveswitch (event.type)with emptydefault:, so unknown event types are silently ignored. No runtime break and no TS break (the widened union still type-checks against an empty default). A real consequence worth flagging: these handlers now fire on everyinsert.textkeystroke anddrag.dragovermousemove, each hitting the switch and falling through todefault. Sub-millisecond per event but a real hot-path cost on the most-used PT input. Worth measuring post-merge if anything feels sluggish.Canvas (
sanity-io/canvas): Fiveeditor.onsubscriptions — all subscribe by specific type ('selection','blurred','value changed'). Unaffected. No exposure to the'unset'deprecation removal.Open questions
editor.on('patch', …)carries Sanity patches. Aeditor.on('operation', …)channel carrying engine operations (insert_node,unset,set, etc.) would let plugins derive state likelistIndexMapandblockIndexMapoutside the engine without re-deriving fromvalue. Follow-up.drag.dragovermirrors the DOM event name verbatim. Verbose but explicit. Worth a sanity check before locking the @public surface.Spec:
/specs/shared-state-primitive.mdv5.