diff --git a/src/servers/plugins/npm-deps.ts b/src/servers/plugins/npm-deps.ts index 09487c7b..6f1abfea 100644 --- a/src/servers/plugins/npm-deps.ts +++ b/src/servers/plugins/npm-deps.ts @@ -1,3 +1,4 @@ +import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { PLATFORM_DIR } from '../data-path'; import type { DiscoveredPlugin } from './manifest'; @@ -104,7 +105,33 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi return { ok: true, ran: false, output: '', conflicts }; } - const body = { + /* + Packages the PLATFORM owns, which a plugin may never bring its own copy of. + + React holds module-level state — the hook dispatcher — so two copies in one page is not a duplicated + dependency, it is a broken one. The second instance's `useRef` reads a null dispatcher and every panel + fails to render with "Cannot read properties of null (reading 'useRef')", which names nothing that + would lead somebody here. + + It is easy to arrive at by accident and hard to diagnose: nobody asks for React. `@tiptap/react` + declares it as a PEER, bun installs peers, and the copy lands in plugins/node_modules where it wins + resolution for everything under it. That took the fofos-admin screen down the moment tiptap arrived. + + Stripped rather than pinned. Matching the platform's version would still be two copies, and two copies + is the whole problem — bun resolves up the tree, so removing them entirely is what makes a plugin use + the one the platform already loaded. + + Removed AFTER the install as well as before it, and the second is the one that does the work: no + plugin declares React, so there is nothing to strip from the merged list. It arrives as a PEER of + something that does, which bun installs and the generated file never mentions. +*/ +const PLATFORM_OWNED = ['react', 'react-dom']; + + for (const owned of PLATFORM_OWNED) { + if (owned in merged) delete merged[owned]; + } + +const body = { name: '@officer/plugin-dependencies', private: true, '//': 'GENERATED from every plugin manifest by src/servers/plugins/npm-deps.ts. Not edited, not committed.', @@ -117,7 +144,26 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi const proc = Bun.spawn(['bun', 'install'], { cwd: PLUGINS_DIR, stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const code = await proc.exited; - return { ok: code === 0, ran: true, output: `${out}${err}`.trim(), conflicts }; + + /* + Evict the platform's singletons, however they got here. + + This is the step that matters. Stripping the merged list above only covers a plugin ASKING for + React, and none do — it arrives as a peer of something that does, so bun installs it without the + generated file ever naming it. Deleting it afterwards is crude and it is deterministic, which is + the right trade for a failure whose symptom is every panel on a screen refusing to render. + */ + const evicted: string[] = []; + for (const owned of PLATFORM_OWNED) { + const path = join(PLUGINS_DIR, 'node_modules', owned); + if (existsSync(path)) { + rmSync(path, { recursive: true, force: true }); + evicted.push(owned); + } + } + + const note = evicted.length ? `\nremoved ${evicted.join(', ')} — the platform owns those` : ''; + return { ok: code === 0, ran: true, output: `${out}${err}${note}`.trim(), conflicts }; } catch (error) { return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts }; }