Skip to content

feat(engine): M7 — the React adapter, and the layering rule as a test - #98

Open
Alexanderdunlop wants to merge 1 commit into
mainfrom
feat/engine-m7-react
Open

Alexanderdunlop wants to merge 1 commit into
mainfrom
feat/engine-m7-react

Conversation

@Alexanderdunlop

Copy link
Copy Markdown
Owner

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) => unsubscribe and a getState() whose reference changes exactly when something changed — create-editor.ts reassigns state on every applied transaction and on a real selection change, returning early when the selection didn't move. That is precisely useSyncExternalStore'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 subscribe to 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.tsx and dev/mention-flow.ts are 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

  • No component, especially not one taking children. The engine owns its element's children, so <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.
  • No dropdown or styling. ADR 0003 confines the engine to beforeinput, so Arrow/Enter/Escape/Tab are the consumer's keys. An adapter shipping a menu owns keyboard policy for everyone.
  • No state mirror. useMentionQuery is a useMemo, not useState + 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 now src/tests/layering.test.ts: walks src/, 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" to model/doc-length.ts:

model/doc-length.ts imports react
The model, view, input, history and query layers must stay framework-free.
If an adapter needs something from them, the thing to move is the *need*,
not the import.

Only three adapters are real

The plan lists react/ vue/ svelte/ vanilla/. createEditor({ element }) already is the vanilla adapter — an element in, dispatch/subscribe/destroy out, no framework. An adapters/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 @Alex entries that differ only by value — the thing v1 can't distinguish), ArrowDown + Enter inserted the chip as user-3, model and DOM agreed, no console errors.

  • 454 unit tests (was 440), including 11 adapter tests under a new adapters vitest project — its own project so the React plugin stays off logic and dom-smoke, and a framework can't quietly become available to them
  • e2e unchanged at 290 passing / 30 skipped; typecheck and e2e typecheck clean
  • StrictMode double-mount and unmount teardown both covered

Two of my own test expectations were wrong and the engine was right: insertMention appends a trailing space by design, and state is 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 through src/index.ts, so the public surface isn't what the adapter proves. Fine while the package is private: true; worth fixing if it's ever published.

Test by hand

pnpm --filter @mentis/engine dev → http://localhost:5180/react.html

Type @al, arrow around, Enter to insert. Then compare with /index.html — same engine, same behaviour, different framework.

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mentis-docs Ready Ready Preview Aug 10, 2026 5:57am

Request Review

This branch was successfully deployed

1 active deployment
Preview — 165b4fb7 Deployed Aug 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant