Purpose: Capture discovery findings, architectural investigations, and rationale that inform the technical design.
collaborative-editor-awarenessy-codemirror.next@0.3.5 reads state.user for cursor info, but GROWI sets state.editors — causing all cursors to render as "Anonymous" with default blue color todayyCollab in v0.3.5 does NOT support a cursorBuilder option; the cursor DOM is hardcoded in YRemoteCaretWidgetawareness.getStates().delete(clientId) in the current updateAwarenessHandler is an incorrect direct mutation of Yjs-managed internal state; Yjs removes stale entries before emitting updateUserPicture (@growi/ui) does not accept a style prop; dynamic border colors require a wrapper element approachpackages/editor cannot import from apps/app; callback props (onScrollToRemoteCursorReady) are used to cross the package boundaryEditorView.scrollIntoView(pos, { y: 'center' }) (CodeMirror built-in) is sufficient for the scroll-to-cursor feature; no new dependencies requiredcursorBuilder option for yCollab. Does the installed version support it?node_modules/.pnpm/y-codemirror.next@0.3.5_.../src/index.js and y-remote-selections.jsyCollab signature: (ytext, awareness, { undoManager }) => Extension[]; no cursorBuilder parameterYRemoteCaretWidget.toDOM() — hardcoded name-only labelySync, ySyncFacet, YSyncConfig, yRemoteSelections, yRemoteSelectionsTheme, yUndoManagerKeymap. NOT exported: yUndoManager, yUndoManagerFacet, YUndoManagerConfigy-remote-selections.js reads state.user.color and state.user.name, but GROWI awareness sets state.editorsyCollab option. Must replace yRemoteSelections with a custom ViewPlugin. Since yUndoManager/yUndoManagerFacet/YUndoManagerConfig are not in the public API, yCollab must still be used for undo; awareness must be suppressed at call site.awareness.setLocalStateField('editors', { name, color, imageUrlCached, ... })y-remote-selections.js reads: const { color, name } = state.user || {}state.user is always undefined → name = "Anonymous", color = default #30bcedstate.user, or (b) replacing the cursor plugin. Since we are building a rich cursor plugin anyway, the clean fix is (b).EditingUserList intermittently disappears when users are actively editing.use-collaborative-editor-mode.ts source):
Array.from(awareness.getStates().values(), v => v.editors) produces undefined for clients whose awareness state has not yet included an editors fieldArray.isArray(clientList) is always true — the guard never filters undefined valuesEditingUserList maps editingClient.clientId which throws/renders undefined element → React key error or render bail-out, causing the list to disappearawareness.getStates().delete(clientId) for removed clients is redundant and incorrect: the Yjs awareness protocol removes stale entries from the Map before emitting the update event. This mutation may cause stale data re-entry or missed subsequent updates.delete() call; no other changes to awareness-update logic required.yRemoteSelections without losing text-sync or undo functionality?ySync (YSyncPluginValue) reads only conf.ytext — does not touch conf.awarenessyUndoManager reads only conf.undoManager (via yUndoManagerFacet) and conf.ytext (via ySyncFacet) — does not touch awarenessyCollab skips yRemoteSelections and yRemoteSelectionsTheme when awareness is falsy: if (awareness) { plugins.push(yRemoteSelectionsTheme, yRemoteSelections) }yCollab(activeText, null, { undoManager }) therefore produces only: [ySyncFacet.of(ySyncConfig), ySync, yUndoManagerFacet.of(...), yUndoManager, EditorView.domEventHandlers]null as awareness to yCollab to suppress the default cursor plugin, then add yRichCursors(provider.awareness) separately.yRemoteSelections (YRemoteSelectionsPluginValue.update()) broadcasts the local cursor position via awareness.setLocalStateField('cursor', { anchor, head }). If we remove yRemoteSelections, who does this?y-remote-selections.js — not in ySyncyRichCursors ViewPlugin must include equivalent broadcast logic: on each view.update, derive anchor/head from update.state.selection.main, convert to Yjs relative positions, and call awareness.setLocalStateField('cursor', ...)state.cursor field convention (unchanged)yRichCursors is a full replacement for yRemoteSelections, not just an additive decoration layer.| Option | Description | Strengths | Risks / Limitations |
|---|---|---|---|
A: Set state.user alongside state.editors |
Keep existing yRemoteSelections; set both awareness fields |
Minimal code change | No avatar support; maintains the two-field redundancy; cursor info is the name only |
B: Custom ViewPlugin (replace yRemoteSelections) |
yCollab(null) + yRichCursors(awareness) |
Full avatar+name rendering; single source of truth in state.editors; clean separation |
Must re-implement cursor broadcast logic (~30 lines of y-remote-selections.js) |
C: Fork y-codemirror.next |
Patch YRemoteCaretWidget to accept avatar |
Full control | Maintenance burden; diverges from upstream; breaks on package upgrades |
Selected: Option B — replaces yRemoteSelections entirely with a purpose-built yRichCursors ViewPlugin.
yCollab has no cursorBuilder hook; yUndoManager is not publicly exported; default cursor reads wrong awareness fieldstate.user — minimal change but no avatar, still redundant fieldyCollab(activeText, null, { undoManager }) to get text-sync and undo without default cursor, plus a custom yRichCursors(awareness) ViewPlugin for rich cursor renderingstate.editors (GROWI's canonical field), supports avatar, eliminates state.user redundancy, requires ~60 lines of new codeyRichCursors; if y-codemirror.next updates its broadcast logic we won't get those changes automaticallyy-codemirror.next >= 1.x or y-websocket v3, re-evaluate if a native cursorBuilder API becomes available<img> in WidgetType.toDOM()UserPicture is a React component and cannot be used directlydocument.createElement in toDOM(): <img> tag for avatar with onerror fallback to initialsWidgetType.toDOM() returns an HTMLElement; React components cannot be server-rendered in this contextUserPicture avatar rendering; acceptable as cursor widget is presentation-onlyyRichCursors broadcasts cursor positions via awareness.setLocalStateField('cursor', ...) on every update call — same as the original yRemoteSelections. Throttle is not needed because Yjs awareness batches broadcasts internally.<img> may fail to load (404, CORS) — mitigate with onerror handler that replaces the <img> with initials fallback span.awareness.getStates().delete() removal: confirm Yjs v13 awareness update event fires after removing the client from the internal map (verified in Yjs source: removal happens before the event).setLocalStateField('cursor', ...) inside the update() method fires an awareness change event synchronously. If the change listener calls view.dispatch() unconditionally, CodeMirror throws "Calls to EditorView.update are not allowed while an update is in progress". Mitigated by filtering the change listener to dispatch only when at least one remote client is in the changed set (clients.findIndex(id => id !== awareness.doc.clientID) >= 0). This matches the same pattern used by y-remote-selections.js in y-codemirror.next.y-protocols not a direct dependency: y-protocols/awareness exports the Awareness class, but neither @growi/editor nor apps/app list y-protocols as a direct dependency. import type { Awareness } from 'y-protocols/awareness' fails under strict pnpm resolution. Mitigated by deriving the type from the existing y-websocket dependency: type Awareness = WebsocketProvider['awareness'].view.viewport vs view.visibleRanges (discovered during validation): CodeMirror's view.viewport returns the rendered content range, which includes a pre-render buffer beyond the visible area for smooth scrolling. Using it for off-screen classification causes cursors in the buffer zone to be treated as in-viewport, resulting in invisible widget decorations instead of off-screen indicators. Must use view.visibleRanges (the ranges actually visible to the user) for accurate classification. Precedent: setDataLine.ts in the same package already uses view.visibleRanges.view.visibleRanges worked in tests (jsdom with fixed-height containers) but failed in GROWI production.view.visibleRanges and view.viewport return the same range (the full document), because the editor expands to content height and scrolling is handled by the browser page — not CodeMirror's own scroller. Character-position comparison is therefore useless for off-screen detection.plugin.ts:
visibleRanges < viewport): internal-scroll editor (jsdom tests, fixed-height editors) — use character-position boundaries from visibleRangesvisibleRanges == viewport, scrollDOM.height > 0): page-scroll editor (GROWI production) — use view.lineBlockAt(pos) + scrollDOM.getBoundingClientRect() to compute screen Y coordinatesscrollDOM.height == 0): jsdom with 0-height container — skip classification, all cursors get widget decorationsview.coordsAtPos() calls readMeasured() internally, which throws "Reading the editor layout isn't allowed during an update". Must use view.lineBlockAt() (reads stored height map, safe during update) + raw getBoundingClientRect() (not CodeMirror-restricted) instead.arrow_drop_up/arrow_drop_down) rendered as literal text instead of icon.next/font in use-material-symbols-outlined.tsx. Next.js registers the font with a hashed family name (e.g., __MaterialSymbolsOutlined_xxxxx), stored in the CSS variable --grw-font-family-material-symbols-outlined. Hardcoding font-family: 'Material Symbols Outlined' in CodeMirror's baseTheme causes a mismatch — the browser cannot find the font.fontFamily: 'var(--grw-font-family-material-symbols-outlined)' in theme.ts so the hashed name is resolved at runtime.overflow-y: hidden Limitation.page-editor-editor-container inherits overflow-y: hidden from .flex-expand-vert within the .flex-expand-vh-100 context (packages/core-styles/scss/helpers/_flex-expand.scss + apps/app/src/styles/scss/layout/_editor.scss). This clips any content extending beyond .cm-editor's border box. .cm-editor itself has no overflow restriction..cm-editor's border box. Arrow icons use clip-path and negative margins to visually align with the border without extending past it.requestMeasureview.coordsAtPos() cannot be called during update() (throws "Reading the editor layout" error). Horizontal positioning must be deferred.replaceChildren(), call view.requestMeasure() to schedule a read phase (coordsAtPos → screen X) and write phase (style.left + transform: translateX(-50%)). For virtualized positions (outside viewport), fall back to contentDOM.getBoundingClientRect().left + col * view.defaultCharacterWidth.UserPicture avatars dynamically per user.UserPicture.tsx in packages/ui/src/components/UserPicture.tsx accepts only { user, size, noLink, noTooltip, className }. The className is applied to the <img> element (not the root <span>). There is no style prop forwarded to either element.borderColor via UserPicture's own props. Must wrap in a parent element with an inline border style. The border border-info className on UserPicture is removed; the wrapper element provides the colored border.use-collaborative-editor-mode (in packages/editor) needs to provide a scroll function to EditingUserList (in apps/app). Direct import from apps/app → packages/editor is the existing direction; reverse import is prohibited.onEditorsUpdated callback in Configuration follows exactly this pattern: packages/editor calls a callback provided by apps/app. The same pattern is appropriate for onScrollToRemoteCursorReady.Configuration type with the new callback.EditorView.scrollIntoView(pos: number, options?: { y?: 'nearest' | 'start' | 'end' | 'center' }) is the standard CodeMirror API. Dispatching { effects: EditorView.scrollIntoView(pos, { y: 'center' }) } scrolls the editor so the position is vertically centered. No additional plugins or dependencies required.Y.createAbsolutePositionFromRelativePosition(cursor.head, ydoc) which is already used in plugin.ts.scrollToRemoteCursorAtom stores a (clientId: number) => void function. useSetAtom returns a setter that is passed as the onScrollToRemoteCursorReady callback.setAtom(fn) is treated as setAtom(prev => fn(prev)), not setAtom(fn_as_value). When onScrollToRemoteCursorReady(scrollFn) was called, Jotai invoked scrollFn(null) (current atom value) as if it were an updater, then stored scrollFn's return value (undefined) in the atom — the scroll function was never stored.[scrollToRemoteCursor] called with clientId: null appeared in logs immediately after "scroll function registered", and the atom value flipped to undefined.Solution: Wrap the function value in useSetScrollToRemoteCursor:
setAtom(() => fn); // updater that returns the function value
This pattern must be applied to any Jotai atom that stores a function value.
Implication: When designing Jotai atoms that store callbacks or any function-typed value, the setter must always use the () => value wrapper form. Document this in code review checklists for Jotai usage.
UserPicture in a <button> for click handling caused visual misalignment and layout instability.noTooltip is not set, UserPicture uses a withTooltip HOC that returns a React Fragment (<span><img/></span> + <UncontrolledTooltip/>). As flex children of the <button>, the Fragment's two children introduced unpredictable layout. Additionally, the <span> as an inline element contributed ghost space from line-height, making the circular border appear offset.noTooltip to UserPicture to get a predictable single-child render (<span><img/></span>)d-inline-flex align-items-center justify-content-center p-0 bg-transparent rounded-circleline-height: 0 to .avatar-wrapper in the CSS module to eliminate inline ghost spaceborder: 2px solid ${color}EditorView.scrollIntoView dispatches a CodeMirror state effect that CodeMirror resolves by scrolling view.scrollDOM. Setting view.scrollDOM.style.scrollBehavior = 'smooth' before the dispatch causes the browser to animate the scroll. Restoring the value after ~500 ms (typical animation window) avoids affecting other programmatic scrolls.view.scrollDOM is the actual scrolling element. In GROWI's page-scroll setup, the effective scrolling element may be a parent container; if smooth scrolling does not animate as expected, the scrollBehavior may need to be set on the parent scroll container instead.scrollFn used by EditingUserList. The natural approach would be yRichCursors(awareness, { onClickIndicator: scrollFn }), but this fails because yRichCursors and scrollFn are created in two separate useEffect calls with slightly different dependency sets.scrollFn is passed as a plain value, every time the scroll function is recreated (on provider/activeDoc/codeMirrorEditor change), the extension array must also be recreated — causing a full CodeMirror extension reload. This is expensive and unnecessary.scrollCallbackRef = useRef(null) to yRichCursors. The plugin captures the ref object (stable reference across re-renders). The scroll-function registration effect updates .current silently without touching the extension.ScrollCallbackRef type ({ current: Fn | null }) is defined in packages/editor without importing React, making it usable in the non-React CodeMirror extension context.EditingUserList. The UserPicture component's withTooltip HOC returns a React Fragment (<span><img/></span> + <UncontrolledTooltip/>), which caused layout instability when used inside a flex <button> (Phase 2 finding). The initial approach (Phase 2) was to use noTooltip + external UncontrolledTooltip at the wrapper level, but design review identified this as a workaround that would need to be repeated by every consumer facing the same Fragment/flex issue.withTooltip HOC returns a Fragment because UncontrolledTooltip is placed as a sibling of the wrapped component. While UncontrolledTooltip uses ReactDOM.createPortal (tooltip content renders to document.body), the Fragment still produces two React children at the parent level, which can destabilize flex layout.UncontrolledTooltip is a portal, it can be placed as a child of the root <span> instead of a sibling. As a portal child, it occupies no DOM space in the parent — only the <img> is a visible child. The root element becomes a single <span> with predictable layout behavior in any container type.withTooltip HOC. Move tooltip rendering inline into UserPicture's render function:
rootRef = useRef<HTMLSpanElement>(null) unconditionally (hooks rules compliant)rootRef to UserPictureRootWithoutLink/UserPictureRootWithLink via forwardRef (they already support it)UncontrolledTooltip as a child of the root element alongside imgElementwithTooltip HOC functionwithTooltip is not exported — it's only used internally in UserPicture.tsx. The public API (Props: user, size, noLink, noTooltip, className) is unchanged. All existing consumers (30+ usages across apps/app) are unaffected.noTooltip usages (16 call sites): Consumers that pass noTooltip (sidebar dropdowns, inline notifications, comment editors, conflict modals) continue to suppress tooltips. EditingUserList is the only consumer that removes noTooltip to gain the tooltip.EditingUserList no longer needs external tooltip code (UncontrolledTooltip, id generation, clientId-based targeting). The AvatarWrapper sub-component is simplified to just a <button> wrapping <UserPicture> with color border.node_modules/.pnpm/y-codemirror.next@0.3.5_.../src/