feat(engine): M7 — the React adapter, and the layering rule as a test - #98
Open
Alexanderdunlop wants to merge 1 commit into
Open
Alexanderdunlop wants to merge 1 commit into
Alexanderdunlop wants to merge 1 commit into
Conversation
ADR 0016 — an adapter is framework lifecycle and reactivity glue, nothing else. Two hooks, 130 lines, in src/adapters/react/. The victory lap was real: it needed nothing new from src/. Not one export, not one signature change. The engine already had subscribe(listener) => unsubscribe and a getState() whose reference changes exactly when something changed, which is useSyncExternalStore's contract arrived at in M1 and M3 with React nowhere in the room. That was not foresight — ADR 0006 forced the query to be derived, which forced subscribe to fire on selection changes, which is what makes a React-derived menu correct. useMentionQuery is a useMemo rather than state, because ADR 0006 says the query is derived; storing it would reintroduce the open/closed flag the archived v2 branch got wrong. There is deliberately no component: the engine owns its element's children, so a component taking children would invite React to render into a tree the engine also writes — v1's central bug one layer up. A ref to an empty element makes that structural. The plan's one hard architectural rule is now enforced rather than trusted. src/tests/layering.test.ts walks src/, extracts every module specifier, and fails naming the file — and asserts it is not vacuous, so deleting the adapters cannot make it pass while proving nothing. Verified it catches a real violation. Also: the plan lists four adapters and only three are real. createEditor() already is the vanilla adapter. Demo at /react.html, driven in a real browser: typed @al, menu filtered, ArrowDown + Enter inserted the chip with its distinct value, no console errors. 454 unit tests (was 440), e2e unchanged at 290/30. Vue and Svelte outstanding — the pattern is established, but claiming the layering holds for three frameworks on the evidence of one is the overreach ADR 0016 is about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Starts M7. Independent of #97 (parking M6) except that both touch
docs/plan.md— merging #97 first avoids a conflict.ADR 0016: an adapter is framework lifecycle and reactivity glue, nothing else. Two hooks, 130 lines.
The victory lap was real, and it's worth being specific about why
The adapter needed nothing new from
src/. Not one export, not one signature change.The engine already had
subscribe(listener) => unsubscribeand agetState()whose reference changes exactly when something changed —create-editor.tsreassignsstateon every applied transaction and on a real selection change, returning early when the selection didn't move. That is preciselyuseSyncExternalStore's contract, arrived at in M1 and M3 with React nowhere in the room.And it wasn't foresight about React. It came from ADR 0006 forcing the query to be derived, which forced
subscribeto fire on selection changes, which is the thing that makes a React-derived menu correct. A constraint adopted for its own reasons paid out in a layer that didn't exist yet.dev/react-demo.tsxanddev/mention-flow.tsare the same shape — subscribe, derive the query, own the keys, dispatch to insert — because the engine's contract never assumed either. That correspondence is the proof, and it's what M2.5 meant by building the dropdown in the harness as "a rehearsal for the M7 adapters".What's deliberately not in it
<Mentis>{...}</Mentis>would invite React to render into a tree the engine also writes — mentis v1's central bug relocated one layer up. A ref to an element the consumer leaves empty makes the rule structural rather than documented.beforeinput, so Arrow/Enter/Escape/Tab are the consumer's keys. An adapter shipping a menu owns keyboard policy for everyone.useMentionQueryis auseMemo, notuseState+ effect. Storing derived state is the open/closed flag ADR 0006 already refused once.The hard rule is now enforced, not trusted
The plan's one architectural rule — nothing below
adapters/may import a framework — was a discipline. It's nowsrc/tests/layering.test.ts: walkssrc/, extracts every module specifier, fails naming the file and the import.It also asserts it is not vacuous — that the adapters themselves do import a framework — so deleting them can't make the rule pass while proving nothing. Confirmed it catches a real violation by adding
import { useMemo } from "react"tomodel/doc-length.ts:Only three adapters are real
The plan lists
react/ vue/ svelte/ vanilla/.createEditor({ element })already is the vanilla adapter — an element in,dispatch/subscribe/destroyout, no framework. Anadapters/vanilla/could only re-export it under a second name, and the plan's non-goals say cap this ruthlessly.Verification
Demo at
/react.html, driven in a real browser rather than asserted: typed@al, the menu filtered to three (including the two@Alexentries that differ only byvalue— the thing v1 can't distinguish), ArrowDown + Enter inserted the chip asuser-3, model and DOM agreed, no console errors.adaptersvitest project — its own project so the React plugin stays offlogicanddom-smoke, and a framework can't quietly become available to themTwo of my own test expectations were wrong and the engine was right:
insertMentionappends a trailing space by design, andstateis non-null on the first commit because attachment happens in the ref callback. Both now assert the real behaviour — the second as a deliberate pin, since an adapter that attached in an effect would flash an empty menu.Still to do
Vue and Svelte. The pattern is established and "~100 lines each" looks right, but claiming the layering holds for three frameworks on the evidence of one is the exact overreach ADR 0016 is about. Also unverified: concurrent features, and SSR — the engine needs a real element, and what an adapter should do during hydration hasn't been designed.
One honest cost recorded in the ADR:
src/adapters/react/imports engine internals directly rather than throughsrc/index.ts, so the public surface isn't what the adapter proves. Fine while the package isprivate: true; worth fixing if it's ever published.Test by hand
pnpm --filter @mentis/engine dev→ http://localhost:5180/react.htmlType
@al, arrow around, Enter to insert. Then compare with/index.html— same engine, same behaviour, different framework.🤖 Generated with Claude Code