diff --git a/src/components/os/MobileAppShell.tsx b/src/components/os/MobileAppShell.tsx
index 4cf7777..ad472ce 100644
--- a/src/components/os/MobileAppShell.tsx
+++ b/src/components/os/MobileAppShell.tsx
@@ -1,4 +1,4 @@
-import { Suspense, useMemo, useState } from 'react';
+import { Suspense, useCallback, useMemo, useState } from 'react';
import { ChevronLeft, LayoutGrid, Zap } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ErrorBoundary } from '@/components/ErrorBoundary';
@@ -14,6 +14,7 @@ import {
import { useWindowManager } from '@/os/useWindowManager';
import { desktopApps, getApp } from '@/os/registry';
import { cn } from '@/lib/utils';
+import type { AppParams } from '@/os/types';
/**
* On a phone the window metaphor only gets in the way, so the same apps and
@@ -30,6 +31,23 @@ export function MobileAppShell() {
[windows, focusedId],
);
const activeApp = active ? getApp(active.appId) : undefined;
+ const activeId = active?.id;
+
+ // Stable per-window callbacks: apps set their title from an effect keyed on
+ // `setTitle`, so a fresh function on every shell render would re-run it.
+ const setTitle = useCallback(
+ (title: string) => {
+ if (activeId) setWindowTitle(activeId, title);
+ },
+ [activeId, setWindowTitle],
+ );
+
+ const setParams = useCallback(
+ (params: AppParams) => {
+ if (activeId) setWindowParams(activeId, params);
+ },
+ [activeId, setWindowParams],
+ );
return (
@@ -108,8 +126,8 @@ export function MobileAppShell() {
setWindowTitle(active.id, title)}
- setParams={(params) => setWindowParams(active.id, params)}
+ setTitle={setTitle}
+ setParams={setParams}
/>
diff --git a/src/os/windowReducer.test.ts b/src/os/windowReducer.test.ts
new file mode 100644
index 0000000..8d41dcf
--- /dev/null
+++ b/src/os/windowReducer.test.ts
@@ -0,0 +1,53 @@
+import { lazy } from 'react';
+import { describe, expect, it } from 'vitest';
+import { Info } from 'lucide-react';
+import { initialState, windowReducer } from './windowReducer';
+import type { AppDefinition } from './types';
+
+const viewport = { width: 1280, height: 800 };
+
+const app: AppDefinition = {
+ id: 'about',
+ title: 'About',
+ description: 'What this is and how it works',
+ icon: Info,
+ category: 'system',
+ component: lazy(async () => ({ default: () => null })),
+ defaultSize: { width: 400, height: 400 },
+ minSize: { width: 200, height: 200 },
+};
+
+function openOne() {
+ const state = windowReducer(initialState, { type: 'OPEN_APP', app, viewport });
+ return { state, id: state.windows[0].id };
+}
+
+describe('windowReducer', () => {
+ it('opens an app and focuses it', () => {
+ const { state, id } = openOne();
+ expect(state.windows).toHaveLength(1);
+ expect(state.focusedId).toBe(id);
+ });
+
+ // Apps set their window title from an effect that re-runs whenever the
+ // callback identity changes. Handing back a fresh state for a title that did
+ // not change re-renders every context consumer, which re-runs the effect,
+ // which dispatches again — an endless render loop.
+ it('returns the same state when a no-op title is set', () => {
+ const { state, id } = openOne();
+ const next = windowReducer(state, { type: 'SET_TITLE', id, title: 'About' });
+ expect(next).toBe(state);
+ });
+
+ it('still applies a title that differs', () => {
+ const { state, id } = openOne();
+ const next = windowReducer(state, { type: 'SET_TITLE', id, title: 'About — v2' });
+ expect(next).not.toBe(state);
+ expect(next.windows[0].title).toBe('About — v2');
+ });
+
+ it('returns the same state for an unknown window id', () => {
+ const { state } = openOne();
+ expect(windowReducer(state, { type: 'SET_TITLE', id: 'nope', title: 'x' })).toBe(state);
+ });
+});
diff --git a/src/os/windowReducer.ts b/src/os/windowReducer.ts
index 8901a17..e1f6fc3 100644
--- a/src/os/windowReducer.ts
+++ b/src/os/windowReducer.ts
@@ -54,8 +54,12 @@ function mapWindow(
let changed = false;
const windows = state.windows.map((win) => {
if (win.id !== id) return win;
- changed = true;
- return fn(win);
+ const next = fn(win);
+ // Only a window that actually came back different counts as a change:
+ // returning a fresh state object for a no-op update makes every consumer
+ // of the context re-render, which is enough to spin a render loop.
+ if (next !== win) changed = true;
+ return next;
});
return changed ? { ...state, windows } : state;
}