refactor(desktop): move Module Hub controller below AppShell - #4315
refactor(desktop): move Module Hub controller below AppShell#4315chihumyum wants to merge 3 commits into
Conversation
8b76083 to
68eca8d
Compare
|
@Astro-Han Hi, can you take a look? Thanks. |
68eca8d to
c4065cc
Compare
|
Working on it! The new head also need a rebase. |
AppShell owned the Goal controller, so every Goal state change re-rendered the shell and, through it, the whole chat surface. The controller's own token update re-rendered 629 fibers to repaint an indicator. Move production ownership into `GoalProvider` and publish the composer, indicator, and dialog projections separately, each at the boundary that actually reads it. The two authorized Desktop leaves take their projection from a context consumer and hand it to the real `Composer` / `ChatView` props, so the render scope stops at the subtree instead of the shell. The transport shapes are indexed off those components' own prop contracts, which makes the handoff compile-checked rather than structural, and the leaf `Omit`s remove the props from what the parent may pass at all. WorkHub and Workbar mount their own `Composer` / `ChatView` without the Session Goal projections and are unaffected. Goal business behavior stays in `use-goal-controller.ts`. AppShell loses its Goal controller and model reads along with the Goal prop plumbing, and `useGoalController` stays out of the production barrel. Unique-controller, unique-production-consumer, forbidden-hook, leaf-`Omit`, AppShell Hook-budget, and renderer-architecture guards keep ownership from returning. Controller-owned renderer work improves decisively: rendered fibers 629 -> 49 (-92.2%) and renderer busy JS 5.016 -> 1.061 ms (-78.9%), every pair improved with disjoint ranges. The complete `goal.pause` path is directional only (-5.0% busy JS, overlapping ranges, commits and fibers flat), so this is not an end-to-end pause speedup. One transient UI state changes: while the selected Session is not yet owner-backed by the hydrated catalog, the unusable "Set Goal..." entry stays hidden instead of briefly appearing and doing nothing. Stabilizing the producer of `reportError` is left to #4315, where the duplication is visible. Generated-by: Codex
c4065cc to
a09ebab
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed exact head a09ebabd7f5076acddb621dc00b5908d70eeb6bc (+1715/-62, 18 files). MERGEABLE; plan passes, heavy still running at the time of writing. Splitting the diff: the guard (check-renderer-architecture.mjs + its fixtures + renderer-architecture.json) is about 1,070 added lines, the renderer change and its coverage about 640.
Problem
Demonstrated, and demonstrated the right way. useModuleHubController was called in AppShellContent's render body, so in React terms its scope was the whole tree. The paired Electron/CDP run and the deterministic render-count test say the same thing from two directions, and the description is careful to bound the wall-clock number rather than generalize it. The scenario the test pins — a Scheduled Tasks change rendering the shell and an unrelated subtree once, versus zero after scoping — is the scenario the problem statement describes.
It also lands where the existing gate said it should. scripts/check-app-shell-hooks.mjs prescribes this exact shape in its own header — the call site moves into a provider, React bails out of children, the entry disappears — and warns that cross-feature intent the shell issues must stay an explicit command rather than become an implicit subscription bought to lower a number. The command port is that explicit command, and the hook entry disappears rather than shrinking. This is the prescribed migration, not a new interpretation of it.
Solution
I traced the bail-out rather than taking it on trust, and it holds. ModuleHubProvider receives AppShell's frame as children, so on a Module Hub state change the three context Providers are rebuilt with the same children element reference and React skips that subtree; only the actual context consumers wake. Each reader reads exactly one context, so a Scheduled Tasks change does not wake the skill-revision boundary and vice versa. ModuleHubHost reads the host model at its own position instead of receiving it from the shell.
The command port's connect returning () => { if (target === next) target = null } is the right guard, and it is load-bearing: controller.commands is nested inside the controller's single useMemo, whose deps include input.openSession — a fresh arrow on every AppShellContent render — so the connect effect re-runs on every shell render. Cleanup-then-connect ordering makes that churn harmless, but it is churn that only that identity check keeps safe. Worth a comment at createModuleHubCommandPort saying so; the reason it is written that way is not obvious from the code.
Findings
P3 — two required props became optional-with-default and are now injected invisibly to the type checker. ComposerMentionsSurface.skillCatalogRevision goes from number to number? with a = 0 default at the reader, and scheduledTasks leaves SessionNavigationProvider's call site; both arrive through cloneElement. ReactElement<SkillCatalogRevisionTargetProps> constrains the child only to accept an optional prop, so the boundary would type-check against almost any element, and a future edit that drops or reorders the wrapper compiles cleanly and silently pins the composer's Skills projection at revision 0 — an installed Skill would not appear in @ until restart. Not reachable today: each provider has exactly one mount site, and module-hub-boundary.test.ts asserts the wrapper's presence by string match. But the guarantee moved from the compiler to a string-matching test, which is a downgrade in a PR whose subject is making ownership provable. Smallest fix: keep the props required and have the boundary render a typed wrapper, or export useModuleHubScheduledTasks / useModuleHubSkillCatalogRevision and let each reader call it — which removes cloneElement and both boundary components outright. The second is more invasive because it points composer-mentions.tsx at the feature's public entry; I would still prefer it, but the call is yours.
P3 — one invariant is now asserted by three mechanisms, and this PR adds to the weakest. That the controller is called once, inside ModuleHubProvider, through a direct import, and is absent from the public entry, is now checked by the new controllerOwners policy; by the existing exact-inventory hook gate, which already fails if useModuleHubController reappears in AppShellContent; and by three new exact-string assertions added here to module-hub-boundary.test.ts (const controller = useModuleHubController(input);, commandPort.connect(controller.commands), productionEntry.includes('useModuleHubController')). The first of those breaks on a parameter rename or a formatter change while proving strictly less than the checker does. If controllerOwners is the authority for provenance, those three assertions should not land with it.
P3 — a third public seam for one symbol. stories.ts re-exports two fakes from testing.ts and adds ModuleHubHostView, and the feature-boundary regex widens from index|testing to index|stories|testing to permit it. Exporting ModuleHubHostView from testing.ts gets the same story coverage with two seams instead of three and leaves the boundary rule untouched. If the intent is that stories must not reach the fakes-plus-controller surface, say so in stories.ts — as written the file reads as a convenience barrel.
On the guard's size
The atomicity argument is sound: a contract with no registered owner proves nothing, and an owner with no contract can be undone silently. I am not asking for a split. But I want to name what the 1,070 lines buy, because the registry has one entry and the description itself frames this as a cooperative guard rather than a proof against hostile code. Under that framing, the evasion classes divide: namespace imports, barrel re-exports, and private intermediaries are things a cooperative author does by habit, so covering them is real value; eval and runtime loads are not, and the checker's own framing concedes it. Registering owner two costs five lines of JSON, which is the leverage — I would just rather see the description claim that leverage than the evasion breadth.
Next step
This is ready. None of the three findings is a reason to hold the migration, and I would rather this land and unblock the next owner in #3439 than trade a demonstrated improvement for P3 polish. Take the first one if you touch the branch again — it is the only one that trades a compiler guarantee for a string match, inside a change whose whole subject is provability — and treat the other two as cleanup you can fold into the next controller migration, when the second registered owner will show whether the duplication is worth carrying.
The one thing still outstanding is mechanical: heavy was running when I finished, so merge on a green run of a09ebabd7. My evidence boundary: I read validateControllerOwners and the config schema closely and spot-checked the fixtures, but I did not audit all 70, and I did not re-run your Electron benchmark.
AI-assisted review: I used Maka to split and read the diff, to trace the context bail-out and the command-port lifecycle against the branch's own source, and to draft this comment. I verified the render-scope mechanism, the useMemo dependency that makes the connect effect re-run, the single mount site of each provider, and the overlap between the three checking mechanisms myself. I did not re-run your Electron benchmark and did not audit all 70 checker fixtures; I own everything above.
…che#4316) AppShell owned the Goal controller, so every Goal state change re-rendered the shell and, through it, the whole chat surface. The controller's own token update re-rendered 629 fibers to repaint an indicator. Move production ownership into `GoalProvider` and publish the composer, indicator, and dialog projections separately, each at the boundary that actually reads it. The two authorized Desktop leaves take their projection from a context consumer and hand it to the real `Composer` / `ChatView` props, so the render scope stops at the subtree instead of the shell. The transport shapes are indexed off those components' own prop contracts, which makes the handoff compile-checked rather than structural, and the leaf `Omit`s remove the props from what the parent may pass at all. WorkHub and Workbar mount their own `Composer` / `ChatView` without the Session Goal projections and are unaffected. Goal business behavior stays in `use-goal-controller.ts`. AppShell loses its Goal controller and model reads along with the Goal prop plumbing, and `useGoalController` stays out of the production barrel. Unique-controller, unique-production-consumer, forbidden-hook, leaf-`Omit`, AppShell Hook-budget, and renderer-architecture guards keep ownership from returning. Controller-owned renderer work improves decisively: rendered fibers 629 -> 49 (-92.2%) and renderer busy JS 5.016 -> 1.061 ms (-78.9%), every pair improved with disjoint ranges. The complete `goal.pause` path is directional only (-5.0% busy JS, overlapping ranges, commits and fibers flat), so this is not an end-to-end pause speedup. One transient UI state changes: while the selected Session is not yet owner-backed by the hydrated catalog, the unusable "Set Goal..." entry stays hidden instead of briefly appearing and doing nothing. Stabilizing the producer of `reportError` is left to apache#4315, where the duplication is visible. Generated-by: Codex
a09ebab to
e95a965
Compare
|
@Astro-Han Rebased onto current main b714a39 and resolved the generated renderer-architecture/Astryx inventory conflicts. The new exact head is e95a965. Local validation passed: renderer architecture 80/80 against upstream/main, AppShell hook scope, Astryx inventory and fixtures, format, lint, full workspace typecheck, Desktop production build, and the Module Hub boundary/provider tests 6/6. Could you take another look when the new CI run settles? Thanks. |
e95a965 to
b8152e2
Compare
|
Follow-up to the review, pushed as a separate commit on top of the rebase onto
Two small changes keep Local verification on this head with Node 24: desktop Automated update from Claude Code on behalf of the PR author. |
b8152e2 to
0ddea0d
Compare
|
Rebased again onto current Same local verification on this head with Node 24: desktop Automated update from Claude Code on behalf of the PR author. |
0ddea0d to
5efcc07
Compare
|
@Astro-Han could you take a look at the follow-up when you have a moment? This PR gates #4491 and #4498 (both register in Since your approval the branch gained one commit, Local verification on this head with Node 24: desktop Posted by Claude Code on behalf of the PR author. |
Generated-by: OpenAI Codex
Generated-by: OpenAI Codex
5efcc07 to
6467663
Compare
|
Rebased once more onto Posted by Claude Code on behalf of the PR author. |
Replace the cloneElement boundaries with a typed render prop so the Scheduled Tasks and Skill catalog revision props stay required at their readers, drop the source-string provenance assertions that duplicated the controllerOwners guard, export ModuleHubHostView from the public entry instead of a stories seam, and document why the command port's cleanup identity check is load-bearing. Generated-by: Claude Code
6467663 to
f1eec15
Compare
|
The run on Posted by Claude Code on behalf of the PR author. |
Summary
controllerOwnersarchitecture policy that pins a controller implementation and symbol to one feature-owned JSX Providermain: an owner contract cannot be removed or redirected, and its call count may only retire from1to0useModuleHubControllerfromAppShellContentintoModuleHubProviderThis intentionally keeps the root guard and the first protected migration in one atomic PR: the migration never lands without its anti-regression contract, and the contract lands with a real registered owner.
Refs #4582 (supersedes #3439)
Performance evidence
I measured the ownership change in one Electron renderer instance with an isolated temporary profile. The run alternated legacy/scoped blocks in 7 paired rounds, with 30 awaited state updates per block. CDP CPU profiling used a 100 microsecond sampling interval; Performance metrics were sampled around each block.
All 7/7 paired blocks favored the scoped owner.
This is a controlled scope benchmark: it isolates the same state update at the former AppShell owner versus the new Provider owner. It is not a claim that end-user interactions or the whole application are 94% faster; Module Hub updates are also less frequent than session switching.
The permanent, non-wall-clock regression test uses the real controller twice with identical fake services. A Scheduled Tasks update causes the legacy shell, unrelated subtree, and reader to render once; after scoping, the shell and unrelated subtree stay at zero while only the reader renders. It also verifies that a Skills refresh wakes only the Composer revision reader and that stale command-port cleanup cannot detach a newer controller target.
The temporary benchmark probes and driver were removed from the final diff.
Ownership guarantee
The previous debt ledger could ratchet hook/import/token counts, but it could not prove controller provenance or unique runtime ownership. The new contract additionally checks:
/indexpublic-entry importsThis is a cooperative static architecture guard, not a hostile-code proof against mechanisms such as
eval. Future controller migrations should register their owner incontrollerOwners; direct new stateful hooks in AppShell remain blocked by the existing AppShell hook/debt ratchets.Verification
npm run check:renderer-architecture -- --base upstream/main— 70/70 checker fixtures and real checkout passednpm run check:app-shell-hooks— 41 hooks / 79 call sites, exact inventory passednpm run typechecknpm run rebuildnpm --workspace @maka/desktop run test:dist— 1,849/1,849 passednpm --workspace @maka/desktop run build-storybooknpm --workspace @maka/desktop run smoke:storybook— 243/243 stories passednpm run lintnpm run format:checknpm run astryx:surface-inventory:writenpm run astryx:surface-inventory— 236 files, 1 exclusiongit diff --checkgit merge-tree --write-tree upstream/main HEADAI use
Select exactly one:
Tool(s) and scope: OpenAI Codex designed and implemented the ownership boundary and guardrail, added tests, built and ran the temporary performance harness, performed independent code audits, and prepared this PR. Both commits include
Generated-by: OpenAI Codextrailers.Checklist
Does this PR entail a change in behavior?