From 9fcc9c278a877d9da87bac148b1c2c3ad159c883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 11:07:26 +0000 Subject: [PATCH] seed the registries before render, and delete the dead file-viewer app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` and `` seeded through `useGlobal`'s `initialData`, which is not a write: it applies only to whichever component reads the slot first. They worked entirely by sitting above `` in frontend.tsx — any WorkspaceView that rendered first would have created the slot as `{}`, with no second chance, and drawn every panel on that screen as an empty box. Both are now plain functions taking the QueryClient, called before createRoot().render(). They take the client rather than running as a module-scope side effect because the app list imports every panel app and every panel app imports the Workspace framework; keeping the call in frontend.tsx, the one module that is nobody's dependency, is what stops that being an import cycle. Making useAppRegistry default to the static list was the obvious fix and is exactly that cycle. registerApp and registerWidget go with the components. Nothing ever called either, and a registry that can be added to at runtime is a registry whose contents depend on what has mounted so far. Separately: officerdev/file-viewer was a registration for a provider fed by a `file-viewer:` channel that nothing writes, with availableOnPanel: false so it could not be picked either. The file viewer users actually see is an ephemeral panel from useFileViewerPanels, which supplies the body and header itself and reads the path from the URL. No stored layout referenced the key — zero rows across dashboards, screens, dashboard_defaults, user_state and user_settings — so the meta and its wrapper are deleted rather than repaired. Co-Authored-By: Claude Opus 5 --- src/apps/officer-web/frontend.tsx | 9 ++++-- src/workspaces/hooks/src/useGlobal.ts | 10 ++++++- .../src/AppRegistry/AppRegistry.tsx | 25 ++++++++++++---- .../useAppRegistry/useAppRegistry.ts | 16 +++++----- .../src/WidgetRegistry/WidgetRegistry.tsx | 14 ++++++--- .../src/WidgetRegistry/useWidgetRegistry.ts | 15 ++++------ .../FileViewer/FileViewerPanelWrapper.tsx | 29 ------------------- .../officerdev/src/apps/FileViewer/index.ts | 25 +++++----------- src/workspaces/officerdev/src/index.ts | 1 - 9 files changed, 65 insertions(+), 79 deletions(-) delete mode 100644 src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx diff --git a/src/apps/officer-web/frontend.tsx b/src/apps/officer-web/frontend.tsx index bfdcdd6e..59a1b2db 100644 --- a/src/apps/officer-web/frontend.tsx +++ b/src/apps/officer-web/frontend.tsx @@ -2,7 +2,7 @@ import './lib/console-buffer'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; -import { AppRegistry, WidgetRegistry } from 'officerdev'; +import { seedAppRegistry, seedWidgetRegistry } from 'officerdev'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ColorModeProvider } from '@/components/ui/ThemeProvider'; import { I18nBridge } from '@/lib/I18nBridge'; @@ -20,6 +20,11 @@ const queryClient = new QueryClient({ }, }); +// Before the first render, not from a component inside it: a panel that renders before its registry is +// populated draws an empty box, and `useGlobal`'s initialData gives no second chance to fill it. +seedAppRegistry(queryClient); +seedWidgetRegistry(queryClient); + const elem = document.getElementById('root')!; const app = ( @@ -29,8 +34,6 @@ const app = ( - - diff --git a/src/workspaces/hooks/src/useGlobal.ts b/src/workspaces/hooks/src/useGlobal.ts index d6993ef2..893bca1d 100644 --- a/src/workspaces/hooks/src/useGlobal.ts +++ b/src/workspaces/hooks/src/useGlobal.ts @@ -1,9 +1,17 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; +/** + * Where a `useGlobal` slot lives in the query cache. Exported so a value can be put there before anything + * renders — `initialData` only applies to whichever component reads the slot first, so a slot that must be + * populated regardless of mount order has to be written, not defaulted. + */ +export const globalQueryKey = (key: string | string[]) => + Array.isArray(key) ? ['USE_GLOBAL', ...key] : ['USE_GLOBAL', key]; + export const useGlobal = (key: string | string[], initialData: T | (() => T)) => { const queryClient = useQueryClient(); - const theKey = Array.isArray(key) ? ['USE_GLOBAL', ...key] : ['USE_GLOBAL', key]; + const theKey = globalQueryKey(key); const theInitialData = typeof initialData === 'function' ? (initialData as () => T)() : initialData; const { data } = useQuery({ diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 613704c2..b6ff7dee 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -2,7 +2,6 @@ import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; import { appRegistryMetas as chatMetas } from '../apps/Chat'; -import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer'; import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; @@ -19,14 +18,15 @@ import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as davMetas } from '../apps/Dav'; -import { useAppRegistry } from './useAppRegistry'; +import type { QueryClient } from '@tanstack/react-query'; +import { globalQueryKey } from 'hooks/useGlobal'; +import { metasToRegistry } from './useAppRegistry'; export const apps = [ ...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, - ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, ...widgetMetas, @@ -45,7 +45,20 @@ export const apps = [ ...davMetas, ]; -export const AppRegistry = () => { - useAppRegistry(apps); - return null; +/** + * Put the app list in the cache before the first render, rather than seeding it from a component mounted + * for its side effect. + * + * The old `` seeded through `useGlobal`'s `initialData`, which only applies to whoever + * reads the slot first. It therefore worked entirely by sitting above `` in `frontend.tsx`: any + * `WorkspaceView` that rendered before it created the slot as `{}` and there was no second chance — + * `initialData` is not a write. Every panel on that screen would have rendered as an empty box, for a + * reason visible nowhere near the screen that broke. + * + * It is a function taking the client rather than a module-scope side effect because the app list imports + * every panel app, and every panel app imports the Workspace framework. Keeping the call in `frontend.tsx` + * — the one module that is nobody's dependency — is what keeps that from becoming an import cycle. + */ +export const seedAppRegistry = (queryClient: QueryClient) => { + queryClient.setQueryData(globalQueryKey('APP_REGISTRY'), metasToRegistry(apps)); }; diff --git a/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts b/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts index eae13efa..c7830ba9 100644 --- a/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts +++ b/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts @@ -3,14 +3,16 @@ import { useGlobal } from 'hooks/useGlobal'; export type AppRegistryMeta = { key: string } & AppRegistryEntry; -export function useAppRegistry(initialApps: AppRegistryMeta[] = []) { - const [registry, setRegistry] = useGlobal('APP_REGISTRY', () => metasToRegistry(initialApps)); +const EMPTY_REGISTRY: AppRegistryMap = {}; - const registerApp = (key: string, entry: AppRegistryEntry) => { - setRegistry((prev) => ({ ...prev, [key]: entry })); - }; - - return { registry, registerApp }; +/** + * Read the app registry. It is written once, by `seedAppRegistry`, before anything renders — there is no + * `registerApp` any more because nothing ever called one, and a registry that can be added to at runtime + * is a registry whose contents depend on what has mounted so far. + */ +export function useAppRegistry() { + const [registry] = useGlobal('APP_REGISTRY', EMPTY_REGISTRY); + return { registry }; } export type UseAppRegistryType = ReturnType; diff --git a/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx b/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx index fe010fda..95f14323 100644 --- a/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx +++ b/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx @@ -4,7 +4,10 @@ import { widgetRegistryMetas as pomodoroMetas } from 'widgets/Pomodoro'; import { widgetRegistryMetas as dailyGoalsMetas } from 'widgets/DailyGoals'; import { widgetRegistryMetas as quickNotesMetas } from 'widgets/QuickNotes'; import { widgetRegistryMetas as musicPlayerMetas } from 'widgets/MusicPlayer'; -import { useWidgetRegistry } from './useWidgetRegistry'; +import type { QueryClient } from '@tanstack/react-query'; +import { globalQueryKey } from 'hooks/useGlobal'; +import type { WidgetRegistry as WidgetRegistryMap } from './types'; +import type { WidgetRegistryMeta } from './useWidgetRegistry'; const widgets = [ ...clockMetas, @@ -15,7 +18,10 @@ const widgets = [ ...musicPlayerMetas, ]; -export const WidgetRegistry = () => { - useWidgetRegistry(widgets); - return null; +const metasToRegistry = (metas: WidgetRegistryMeta[]): WidgetRegistryMap => + Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry])); + +/** Same shape, and the same reasoning, as `seedAppRegistry`. */ +export const seedWidgetRegistry = (queryClient: QueryClient) => { + queryClient.setQueryData(globalQueryKey('WIDGET_REGISTRY'), metasToRegistry(widgets)); }; diff --git a/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts b/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts index a7079d25..ad4dc8ca 100644 --- a/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts +++ b/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts @@ -3,15 +3,10 @@ import { useGlobal } from 'hooks/useGlobal'; export type WidgetRegistryMeta = { key: string } & WidgetRegistryEntry; -export function useWidgetRegistry(initialWidgets: WidgetRegistryMeta[] = []) { - const [registry, setRegistry] = useGlobal('WIDGET_REGISTRY', () => metasToRegistry(initialWidgets)); +const EMPTY_REGISTRY: WidgetRegistry = {}; - const registerWidget = (key: string, entry: WidgetRegistryEntry) => { - setRegistry((prev) => ({ ...prev, [key]: entry })); - }; - - return { registry, registerWidget }; +/** Read the widget registry. Written once by `seedWidgetRegistry`; see `useAppRegistry` for the reasoning. */ +export function useWidgetRegistry() { + const [registry] = useGlobal('WIDGET_REGISTRY', EMPTY_REGISTRY); + return { registry }; } - -const metasToRegistry = (metas: WidgetRegistryMeta[]): WidgetRegistry => - Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry])); diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx deleted file mode 100644 index d0c4649c..00000000 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ReactNode } from 'react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { FileViewerProvider } from './FileViewerProvider'; - -type FileViewerChannelState = { - filePath: string; - fileName: string; - root?: string; -} | null; - -const FILE_VIEWER_CHANNEL = 'file-viewer'; - -export const FileViewerPanelProvider = ({ panelId, children }: { panelId: string; children: ReactNode }) => { - const [state] = usePanelChannel(`${FILE_VIEWER_CHANNEL}:${panelId}`, null); - - if (!state) { - return ( -
- No file selected -
- ); - } - - return ( - - {children} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/index.ts b/src/workspaces/officerdev/src/apps/FileViewer/index.ts index a84420eb..5865781c 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/index.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/index.ts @@ -1,25 +1,14 @@ -import type { AppRegistryMeta } from '../../AppRegistry'; -import { Eye } from 'lucide-react'; -import { FileViewerBody } from './FileViewerBody'; -import { FileViewerHeader } from './FileViewerHeader'; -import { FileViewerPanelProvider } from './FileViewerPanelWrapper'; - export { FileViewerView } from './FileViewerView'; export { FileViewerProvider } from './FileViewerProvider'; export { FileViewerHeader } from './FileViewerHeader'; export { FileViewerBody } from './FileViewerBody'; -export { FileViewerPanelProvider } from './FileViewerPanelWrapper'; export { useFileViewer } from './FileViewerContext'; export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types'; -export const appRegistryMetas: AppRegistryMeta[] = [ - { - key: 'officerdev/file-viewer', - name: 'File Viewer', - icon: Eye, - component: FileViewerBody, - header: FileViewerHeader, - provider: FileViewerPanelProvider, - availableOnPanel: false, - }, -]; +// No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard: +// it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header` +// itself along with a provider that reads the path from the URL. The registration that used to sit here +// pointed at a provider fed by a `file-viewer:` channel that nothing in the repo ever wrote, and +// carried `availableOnPanel: false` so it could not be picked either — it would have rendered "No file +// selected" forever. No stored layout referenced it (checked across dashboards, screens, +// dashboard_defaults, user_state and user_settings), so it was removed rather than repaired. diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index ec9ce5d0..b24c0e13 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -90,7 +90,6 @@ export { FileViewerProvider, FileViewerHeader, FileViewerBody, - FileViewerPanelProvider, useFileViewer, getFileType, getLang,