Skip to content

Changed the post settings sections to a narrow session port - #30755

Merged
9larsons merged 2 commits into
mainfrom
slars/editor-settings-port
Sep 17, 2026
Merged

9larsons merged 2 commits into
mainfrom
slars/editor-settings-port

Conversation

@9larsons

@9larsons 9larsons commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

The post settings panel handed every section the whole EditorSessionHandle — about thirty members, including the save dispatchers, the publish flow, the reload and the leave guard. The fourteen files under settings/ use thirteen of them between them, and because the handle moves whenever anything on the session moves, the sections were re-handed a new object for edits they cannot see.

The port

settings/editor-settings-port.ts adds EditorSettingsPort, a Pick<EditorSessionHandle, …> of exactly what the sections read, plus useEditorSettingsPort() which memoizes it over those members. The panel derives it once and passes it down; every section's session prop keeps its name and is retyped to the port. No behaviour changes.

Member Read by
settings access, authors, meta-data, post-history, show-title, social-card, tags, template, the panel's excerpt and featured rows, use-settings-field
editSettings access, authors, show-title, social-card, tags, template, the panel's featured row
stageSettings use-settings-field
commitSettings use-settings-field, the panel's excerpt row
bind delete, meta-data, social-card (bind.title), the panel's excerpt row (bind.excerpt, bind.onExcerptChange)
loadedRecord delete, post-history, social-card
slug meta-data, template, url
createdId delete only
dispose delete only
editSlug url only
restoreRevision post-history only
publishTime publish-date only
editPublishedAt publish-date only

The last six are each a single section's, and they stay in the pick rather than being threaded as extra props.

bind is narrowed the same way the handle is. The sections read three of its nine members, so the port carries bind: Pick<EditorSessionBinding, 'title' | 'excerpt' | 'onExcerptChange'>, built in its own memo over those three values. Carrying the whole bind would have moved the port for initialLexical and the body change handlers, which no section reads.

state is not on the port either. The save engine publishes {kind: 'debouncing'} on the keystroke that schedules an autosave, so state moves on every body keystroke; carrying it would make the port churn exactly as often as the handle and leave the sections no better off. Its one reader, PostHistorySection, which uses it to say a restore was refused because the session expired, now takes it as its own state prop.

No section is memoized yet, so the port's payoff today is the type narrowing plus the callbacks the sections build over it — SocialCardSection's editImage, the onChange behind useImageFieldUpload, is useCallback([network.imageKey, session]) and now holds across a body keystroke instead of being rebuilt on each one. Memoizing the sections is the follow-on the port makes possible; it is not in this change.

memo(KoenigPostEditor)

KoenigPostEditor mounts two composer subtrees and re-rendered on every parent render. That is every sidebar keystroke, and also every keystroke in the body itself: PostEditor holds the word count in its own state and passes setWordCount down as onWordCountChange, so each word the writer types re-rendered both composer subtrees from above. The body case is the larger of the two.

All twelve of its props are already referentially stable across a render of PostEditor:

Prop Stabilised by
cardConfig useMemo over the live visibility and show-title values in editor-screen.tsx
initialLexical string | null off the session binding
placeholder a string built from postType; a fresh but equal string
darkMode boolean from useFocusContext()
cursorDidExitAtTop useCallback([]) in post-editor.tsx (focusExcerpt / focusTitle)
registerAPI useCallback in post-editor.tsx over the screen's own (absent) callback
registerSecondaryAPI same
onChange bind.onLexicalChange, useCallback([session])
onSecondaryChange bind.onSecondaryChange, useCallback([session])
onSecondaryError bind.onSecondaryError, useCallback([session])
onWordCountChange useState setter in post-editor.tsx
onTkCountChange useState setter in post-editor.tsx

Evidence

koenig-post-editor.test.tsx renders the real PostEditor — the component that builds those props — with the Koenig loader stubbed, and counts composer renders while the post title is typed into. The title is PostEditor's own prop, so a keystroke re-renders the whole surface. Two keystrokes:

  • as shipped: 2 composer renders (the initial visible and hidden instances), unchanged by either keystroke
  • with memo removed from KoenigPostEditor: 6 — expected "vi.fn()" to be called 2 times, but got 6 times, two more per keystroke
  • with any one of the props above rebuilt inline at the call site — onTkCountChange={(count) => setBodyTkCount(count)} — 6, the same failure

The test then re-renders with a different postType, which changes placeholder, a prop the body does read, and asserts the count moves to 4, so a passing run is the memo holding renders back rather than the probe missing them. Nothing test-only was added to production code; the seam is the existing loader module.

editor-settings-port.test.tsx drives the real useEditorSession and asserts the port survives a body keystroke: after bind.onLexicalChange, the handle is a different object and the session reads dirty, while the port is the same object. Staging a settings field replaces the port — settings is on it — but not port.bind, and a title edit replaces both, since the sections read the title.

Verified

Run one at a time, from apps/admin, after committing:

  • pnpm exec vitest run src/editor — 1178 passed, 63 files, 17s
  • pnpm --filter @tryghost/admin lint — 0 errors (41 pre-existing warnings elsewhere in the app)
  • pnpm --filter @tryghost/admin exec tsc -b — clean
  • pnpm exec vitest run -c vitest.acceptance.config.ts --maxWorkers=2 src/editor — 385 passed, 25 files, 66s

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Advanced

Run ID: ee827dde-99df-4644-af0a-8e917cada0cf

📥 Commits

Reviewing files that changed from the base of the PR and between a6e7f42 and 293e2fb.

📒 Files selected for processing (2)
  • apps/admin/src/editor/settings/post-settings-sidebar.test.tsx
  • apps/admin/src/editor/settings/post-settings-sidebar.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (6)
  • GitHub Check: Lint
  • GitHub Check: App Playwright Acceptance Tests (@tryghost/admin)
  • GitHub Check: Build Docker Images
  • GitHub Check: Unit tests (Node 24.20.0)
  • GitHub Check: Unit tests (Node 22.23.1)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (5)
Review Admin UI for existing Shade reuse, correct component layer, semantic tokens, accessible interaction states, and whole-sentence translations.

⚙️ CodeRabbit configuration file

Files:

  • apps/admin/src/editor/settings/post-settings-sidebar.test.tsx
  • apps/admin/src/editor/settings/post-settings-sidebar.tsx
Review whether tests prove changed behaviour, meaningful error/edge paths, and externally observable contracts without coupling to implementation details.

⚙️ CodeRabbit configuration file

Files:

  • apps/admin/src/editor/settings/post-settings-sidebar.test.tsx
Review lens: "where does this data become trusted?" Boundary data (HTTP input, external API/SDK responses, env/config, DB/filesystem reads, queue/webhook/event payloads) is `unknown` until validated — Zod by default.

⚙️ CodeRabbit configuration file

Files:

  • apps/admin/src/editor/settings/post-settings-sidebar.test.tsx
  • apps/admin/src/editor/settings/post-settings-sidebar.tsx
Prioritise concrete correctness, security, data-integrity, compatibility, and regression risks.

⚙️ CodeRabbit configuration file

Files:

  • apps/admin/src/editor/settings/post-settings-sidebar.test.tsx
  • apps/admin/src/editor/settings/post-settings-sidebar.tsx
Type-safe boundaries: Fail only if the PR: consumes boundary data (HTTP input, external API/SDK responses, env/config, DB/filesystem reads, queue/webhook/event payloads) without validating it first — Zod by default, another format only wher...

📄 CodeRabbit inference engine (Custom checks)

Files:

  • apps/admin/src/editor/settings/post-settings-sidebar.test.tsx
  • apps/admin/src/editor/settings/post-settings-sidebar.tsx

Walkthrough

KoenigPostEditor is now wrapped in memo, with tests for composer reuse and rebuilding. Settings sections now consume a memoized EditorSettingsPort instead of the full editor session handle. PostHistorySection derives restore errors from SaveEngineState. Related types, documentation, and tests were updated.

Priority: ⬇️ Low

Change: Refactor

Merge Risk: ⚪ Minimal · up to 293e2

The editor settings render optimization has no identified merge-blocking issue.

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary settings-session change. It does not mention the additional memoization work, but it remains specific and directly related to the pull request.
Description check ✅ Passed The description accurately explains the narrow session port, memoization changes, tests, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Type-Safe Boundaries ✅ Passed PASS: The PR does not introduce an unvalidated boundary-data read or a typing bypass for boundary data. Production changes add EditorSettingsPort with Pick-based types, memoization, prop retyping,…
New Files Are Typescript ✅ Passed The pull-request diff adds four files, and all four use TypeScript extensions (.ts or .tsx). It adds no .js, .jsx, .cjs, or .mjs source file. The modified files do not trigger this check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slars/editor-settings-port

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 293e2fb

Command Status Duration Result
nx run @tryghost/admin:test:acceptance ✅ Succeeded 8m 28s View ↗
nx run-many -t test:unit -p @tryghost/admin ✅ Succeeded 4m 26s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 27s View ↗
nx run-many -t lint -p @tryghost/admin,ghost-mo... ✅ Succeeded 1m 44s View ↗
nx run @tryghost/admin:build ✅ Succeeded 17s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded 1s View ↗
nx run @tryghost/e2e:test:fixtures ✅ Succeeded 1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-16 17:25:44 UTC

@9larsons
9larsons force-pushed the slars/editor-stable-session-handle branch from 5c6a0cb to 1226924 Compare September 14, 2026 17:10
@9larsons
9larsons force-pushed the slars/editor-settings-port branch 2 times, most recently from 57c4bd8 to a97b8a5 Compare September 14, 2026 17:29
Base automatically changed from slars/editor-stable-session-handle to main September 14, 2026 20:22
no ref

The sidebar handed every section the whole editing handle: about thirty
members, of which the fourteen settings files use thirteen between them.
The sections could reach the save dispatchers, the publish flow and the
leave guard, and they were re-handed a new object whenever anything on
the session moved.

The panel now derives an `EditorSettingsPort` — a `Pick` of exactly what
the sections read — and memoizes it over those members, so an edit the
panel cannot see leaves the sections' prop alone. The binding is narrowed
the same way: the sections read the title, the excerpt and the excerpt
writer, so the port carries those three leaves rather than the whole
`bind`, whose other members would move the port for an edit no section
reads. The save engine's state stays off the port: it goes to
`debouncing` on every body keystroke, so carrying it would make the port
move as often as the handle does. The history pane, its only reader,
takes it as its own prop.

`KoenigPostEditor` is memoized to match. Every one of its props is
already stable across an edit outside the body, and it mounts two
composer subtrees, both of which were rebuilding on each sidebar
keystroke — and on each body keystroke too, through the word count the
surface holds above them.
@9larsons
9larsons force-pushed the slars/editor-settings-port branch from a97b8a5 to a6e7f42 Compare September 16, 2026 16:01
no ref

The narrow session port only provides a useful render boundary when its consumers participate in memoization. Memoize each section and cover save-state churn so body edits no longer rebuild unrelated settings UI.
@9larsons
9larsons merged commit a9135ee into main Sep 17, 2026
52 checks passed
@9larsons
9larsons deleted the slars/editor-settings-port branch September 17, 2026 14:39
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