From 9719e36723ee74f30d73257c7eb8105be5b8962e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 20:19:35 +0100 Subject: [PATCH] a plugin may not bring its own React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing tiptap for fofos-admin took the whole plugin screen down: "Cannot read properties of null (reading 'useRef')", both panels, nothing rendering. Two Reacts. The platform has 19.2.4; `@tiptap/react` declares React as a PEER, bun installs peers, and 19.2.8 landed in plugins/node_modules where it wins resolution for everything underneath. React holds its hook dispatcher in module state, so a second instance is not a duplicated dependency — it is a broken one, and the error names nothing that would lead anybody here. Easy to reach by accident, since nobody asks for React. It arrives underneath something that does. So platform-owned packages are stripped from the merged list AND deleted after the install, and the second is the one that does the work: no plugin declares React, so there is nothing in the list to strip. Deleting afterwards is crude and deterministic, which is the right trade for a failure whose symptom is every panel on a screen refusing to render. Stripped rather than pinned. Matching the platform's version would still be two copies, and two copies is the entire problem — bun resolves up the tree, so removing them is what makes a plugin use the one already loaded. Co-Authored-By: Claude Opus 5 --- src/servers/plugins/npm-deps.ts | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) 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 }; }