seed the registries before render, and delete the dead file-viewer app
`<AppRegistry />` and `<WidgetRegistry />` 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 `<App />` 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:<panelId>`
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = (
|
||||
<StrictMode>
|
||||
@@ -29,8 +34,6 @@ const app = (
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} />
|
||||
<AppRegistry />
|
||||
<WidgetRegistry />
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</I18nBridge>
|
||||
|
||||
@@ -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 = <T>(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<T>({
|
||||
|
||||
@@ -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 `<AppRegistry />` seeded through `useGlobal`'s `initialData`, which only applies to whoever
|
||||
* reads the slot first. It therefore worked entirely by sitting above `<App />` 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));
|
||||
};
|
||||
|
||||
@@ -3,14 +3,16 @@ import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
export type AppRegistryMeta = { key: string } & AppRegistryEntry;
|
||||
|
||||
export function useAppRegistry(initialApps: AppRegistryMeta[] = []) {
|
||||
const [registry, setRegistry] = useGlobal<AppRegistryMap>('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<AppRegistryMap>('APP_REGISTRY', EMPTY_REGISTRY);
|
||||
return { registry };
|
||||
}
|
||||
|
||||
export type UseAppRegistryType = ReturnType<typeof useAppRegistry>;
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -3,15 +3,10 @@ import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
export type WidgetRegistryMeta = { key: string } & WidgetRegistryEntry;
|
||||
|
||||
export function useWidgetRegistry(initialWidgets: WidgetRegistryMeta[] = []) {
|
||||
const [registry, setRegistry] = useGlobal<WidgetRegistry>('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<WidgetRegistry>('WIDGET_REGISTRY', EMPTY_REGISTRY);
|
||||
return { registry };
|
||||
}
|
||||
|
||||
const metasToRegistry = (metas: WidgetRegistryMeta[]): WidgetRegistry =>
|
||||
Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry]));
|
||||
|
||||
@@ -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<FileViewerChannelState>(`${FILE_VIEWER_CHANNEL}:${panelId}`, null);
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-duck-dark/30 text-sm">
|
||||
No file selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={state.filePath} fileName={state.fileName} root={state.root}>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
};
|
||||
@@ -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:<panelId>` 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.
|
||||
|
||||
@@ -90,7 +90,6 @@ export {
|
||||
FileViewerProvider,
|
||||
FileViewerHeader,
|
||||
FileViewerBody,
|
||||
FileViewerPanelProvider,
|
||||
useFileViewer,
|
||||
getFileType,
|
||||
getLang,
|
||||
|
||||
Reference in New Issue
Block a user