auto-scrollsrc/client/hooks/ does not exist; hooks are collocated with features — a new shared hooks directory is neededsrc/client/hooks/apps/app/src/client/hooks/ does not existfeatures/page-tree/hooks/, features/openai/client/components/.../hooks/src/client/src/client/hooks/ establishes a new pattern for cross-feature hooksapps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsxsearch-result-content-body-containeroverflow-y-scroll — is the scroll unit, not the viewport.highlighted-keyword elements and scroll to the first one using scrollWithinContainerSCROLL_OFFSET_TOP = 30useEffect has no dependency array (fires on every render); no cleanup (intentional per inline comment)scrollIntoView() default is inappropriate; custom scrollTo using scrollWithinContainer is requiredwindow.location.hash is non-empty, the keyword scroll overrides hash scroll after 500ms debounce — must be suppressed via early return guardresolveTarget default (document.getElementById) works correctly; heading id attributes are set by the remark pipelinepackages/remark-drawio/src/components/DrawioViewer.tsx{[GROWI_RENDERING_ATTR]: 'true'} in JSX spread (line 188)removeAttribute(GROWI_RENDERING_ATTR) (line 131)removeAttribute(GROWI_RENDERING_ATTR) (line 148)setAttribute(attr, 'false') on completion/error instead of removeAttributeapps/app/src/features/mermaid/components/MermaidViewer.tsxGROWI_RENDERING_ATTRmermaid.render() async with direct innerHTML assignmentvalue attributedata-growi-rendering — ambiguous (rendering what?)data-growi-is-rendering-in-progress — explicit but verbosedata-growi-rendering-status — implies multiple statesdata-growi-content-rendering — slightly more specificdata-growi-is-content-rendering works welldata-growi-is-content-rendering — clearly a boolean predicate, reads naturally as is-content-rendering="true"/"false"| Option | Description | Strengths | Risks / Limitations | Notes |
|---|---|---|---|---|
| Custom hook with options object | Single hook with configurable resolveTarget and scrollTo callbacks | Clean API, single import, testable | Options object may grow over time | Selected approach |
| Separate hooks per page type | usePageHashScroll, useSearchScroll | Type-specific optimization | Duplicated watch/cleanup logic | Rejected — violates DRY |
| HOC wrapper | Higher-order component wrapping scroll behavior | Framework-agnostic | Harder to compose, less idiomatic React | Rejected — hooks are idiomatic |
useAutoScroll(key, containerId, resolveTarget?, scrollFn?)useAutoScroll(options)key and contentContainerId, optional resolveTarget and scrollTodata-growi-is-content-rendering with values "true" / "false"is-*) is natural for a two-state attribute; content-rendering disambiguates from other rendering concepts@growi/core constant and all consumers[data-growi-is-content-rendering="true"] instead of bare attribute selector[attr] matches both states; value selector is requireduseEffect with if (window.location.hash.length > 0) return; so hash scroll takes priority when a hash is present; keyword scroll proceeds unchanged otherwisescrollIntoView() default scrolls the viewport when SearchResultContent's container has overflow-y-scroll — Mitigation: Provide a custom scrollTo closure using scrollWithinContainer with offset from the container's bounding rect@growi/core, remark-drawio, and consuming apps — Mitigation: Constants are centralized; single constant rename propagates via importssubtree: true may be expensive on large pages — Mitigation: Retained 10s maximum watch timeout from current implementationDiscovered after task 6 implementation during code review conversation.
The task 6 implementation added useContentAutoScroll to SearchResultContent, but this was architecturally incorrect. useContentAutoScroll is URL-hash–driven (if (hash.length === 0) return) and will never activate in the search results context — the search page URL (/search?q=foo) carries no fragment identifier.
The real requirement for SearchResultContent is:
.highlighted-keyword element when content loads, via MutationObserver + 500ms debouncewatchRenderingAndReScroll should re-scroll to the keyword once rendering settlesapps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx contains:
useContentAutoScroll(...) call — should be removeduseEffect with hash guard (if (window.location.hash.length > 0) return) — the guard may also be removable depending on how the hook is refactoredscrollToTargetWithinContainer local helper (shared distance calculation) — keepTwo-phase refactor, designed for the next session:
Phase 1 — Immediate fix (SearchResultContent)
Wire watchRenderingAndReScroll directly into the keyword scroll useEffect:
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (scrollElement == null) return;
const scrollToKeyword = (): boolean => {
const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
if (toElem == null) return false;
scrollToTargetWithinContainer(toElem, scrollElement);
return true;
};
// MutationObserver for incremental content loading (debounced)
const observer = new MutationObserver(() => {
scrollToFirstHighlightedKeywordDebounced(scrollElement);
});
observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
// Rendering watch: re-scroll after drawio/mermaid layout shifts
const cleanupWatch = watchRenderingAndReScroll(scrollElement, scrollToKeyword);
return cleanupWatch;
}, [page._id]);
Remove the useContentAutoScroll import and call entirely.
Phase 2 — Architecture improvement (shared hook)
Reorganize the relationship between useContentAutoScroll and watchRenderingAndReScroll:
watchRenderingAndReScroll (pure function) is the core shared primitive — promote it to a named export so callers other than useContentAutoScroll can use it directlyuseRenderingRescroll(scrollToTarget, deps) that manages the useEffect lifecycle for watchRenderingAndReScroll, making it composableuseContentAutoScroll becomes the hash-navigation–specific hook: hash guard → target resolution → initial scroll → delegates to useRenderingRescrollSearchResultContent keyword scroll becomes: MO-debounce → initial scroll → delegates to useRenderingRescrollscrollIntoView, getElementById resolver) stays in PageView or in useContentAutoScrollResulting dependency graph:
useContentAutoScroll ─┐
├── useRenderingRescroll ── watchRenderingAndReScroll
SearchResultContent ─┘
useRenderingRescroll be a hook (managing useEffect internally) or should callers be responsible for calling it inside their own effect? A hook is more ergonomic; a plain function is more flexible.useEffect has no dependency array (fires every render) and no cleanup — intentional per inline comment. Adding [page._id] deps and a cleanup changes this behavior. Is that safe?useEffect be removed once useContentAutoScroll is also removed from SearchResultContent?Objective: Determine whether extracting a shared useRenderingRescroll hook is architecturally beneficial after tasks 1–7 completion.
Method: Code review of current implementations — useContentAutoScroll (108 lines), watchRenderingAndReScroll (85 lines), SearchResultContent keyword-scroll effect (lines 133–161).
1. Hook extraction is architecturally infeasible for useContentAutoScroll
useContentAutoScroll calls watchRenderingAndReScroll conditionally inside its useEffect:
scrollToTarget() returns true (line 77)React hooks cannot be called conditionally or inside callbacks. A useRenderingRescroll hook would need an "enabled" flag pattern, adding complexity without simplification.
2. Co-located cleanup in SearchResultContent prevents separation
The keyword-scroll useEffect in SearchResultContent (lines 135–160) combines:
watchRenderingAndReScroll for async renderer compensationExtracting the watch into a separate hook would split cleanup across two effects, making the lifecycle harder to reason about.
3. All three design questions from the original research are resolved
| Question | Resolution | How |
|---|---|---|
| Hook vs. function | Plain function | Conditional call inside effect prevents hook usage |
[page._id] deps + cleanup safe? |
Yes, safe | Implemented in task 7.1, working correctly |
| Hash guard removal | Already done | Removed in task 7.1 alongside useContentAutoScroll removal |
4. Current architecture is already optimal
watchRenderingAndReScroll as a plain function returning a cleanup closure is the correct abstraction level:
useEffect (conditional or unconditional)renderHook)Initially recommended closing Task 8 without code changes. However, after discussion the scope was revised from "hook extraction" to "module reorganization" — see below.
Context: The user observed that while a shared useRenderingRescroll hook adds no value (confirmed by analysis above), the current file layout is inconsistent:
useContentAutoScroll lives in src/client/hooks/ (shared) but is PageView-specific (hash-dependent)watchRenderingAndReScroll lives next to that hook as if internal, but is the actual shared primitiveRevised approach:
watchRenderingAndReScroll to src/client/util/ — co-located with smooth-scroll.ts (both are DOM scroll utilities)useContentAutoScroll → useHashAutoScroll and move next to PageView.tsxSearchResultContent into co-located useKeywordRescroll hooksrc/client/hooks/use-content-auto-scroll/ directoryRationale: Module co-location over shared directory. Each hook lives next to its only consumer. Only the truly shared primitive (watchRenderingAndReScroll) stays in a shared directory — and it moves from hooks/ to util/ since it's a plain function, not a hook.