Skip to content

Commit d99786c

Browse files
Keep immersive owner and mode atomic (#726)
Co-authored-by: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Co-authored-by: Möbius Agent <mobius-agent@users.noreply.github.com>
1 parent e18226c commit d99786c

7 files changed

Lines changed: 101 additions & 75 deletions

File tree

frontend/public/mobius-runtime.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3583,6 +3583,7 @@ const MOVE_TOL_PX = 10;
35833583
function makeImmersive({ appId } = {}) {
35843584
let hidden = false;
35853585
const listeners = /* @__PURE__ */ new Set();
3586+
const holds = /* @__PURE__ */ new WeakMap();
35863587
const hasParent = typeof window !== "undefined" && window.parent && window.parent !== window;
35873588
function notify() {
35883589
for (const cb of [...listeners]) try {
@@ -3630,10 +3631,9 @@ function makeImmersive({ appId } = {}) {
36303631
listeners.delete(cb);
36313632
};
36323633
}
3633-
function holdToToggle(el, opts = {}) {
3634+
function holdToToggle(el) {
36343635
if (!el || typeof el.addEventListener !== "function") return () => {};
3635-
if (el.__mobiusHold) return el.__mobiusHold;
3636-
const holdMs = typeof opts.holdMs === "number" ? opts.holdMs : HOLD_MS;
3636+
if (holds.has(el)) return holds.get(el);
36373637
let timer = null;
36383638
let startX = 0;
36393639
let startY = 0;
@@ -3683,7 +3683,7 @@ function makeImmersive({ appId } = {}) {
36833683
if (navigator.vibrate) navigator.vibrate(10);
36843684
} catch (e2) {}
36853685
toggle();
3686-
}, holdMs);
3686+
}, HOLD_MS);
36873687
}
36883688
function onPointerMove(e) {
36893689
if (timer === null) return;
@@ -3709,7 +3709,7 @@ function makeImmersive({ appId } = {}) {
37093709
el.addEventListener("contextmenu", onContextMenu);
37103710
const cleanup = () => {
37113711
release();
3712-
delete el.__mobiusHold;
3712+
holds.delete(el);
37133713
el.style.cursor = prev.cursor;
37143714
el.style.touchAction = prev.touchAction;
37153715
el.style.userSelect = prev.userSelect;
@@ -3722,7 +3722,7 @@ function makeImmersive({ appId } = {}) {
37223722
el.removeEventListener("click", onClickCapture, true);
37233723
el.removeEventListener("contextmenu", onContextMenu);
37243724
};
3725-
el.__mobiusHold = cleanup;
3725+
holds.set(el, cleanup);
37263726
return cleanup;
37273727
}
37283728
return {

frontend/src/components/AppCanvas/AppCanvas.jsx

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,10 @@ function CanvasLoadingBrand({ appName }) {
239239
const AppCanvas = forwardRef(function AppCanvas({
240240
appId, version = 0, appName, appSlug, offlineCapable = false,
241241
capabilityContract = null,
242-
immersive = false,
243-
// The lighter "look like a standalone app" collapse (mode:'bar'): the shell
244-
// hides its toolbar but keeps the status-bar strip, so unlike `immersive`
245-
// (full-bleed) the app is NOT painted under the notch and needs no safe-area
246-
// insets. It still counts as "chrome hidden" for the app's immersive helper
247-
// echo, so the app's toggle()/`.hidden` stay correct.
248-
barCollapsed = false,
242+
// The shell's applied presentation for this app: full-bleed immersive,
243+
// status-bar-preserving chrome collapse, or null. One value keeps safe-area
244+
// forwarding and the runtime echo from observing contradictory booleans.
245+
immersiveMode = null,
249246
// Whether this app is the currently-visible canvas (canvas view + active
250247
// app). One prop, two consumers:
251248
// - `moebius:frame-visibility` — the shell keeps recently-used apps
@@ -422,8 +419,8 @@ const AppCanvas = forwardRef(function AppCanvas({
422419
// listener is live and it can receive frame-init/theme/insets. Per frame,
423420
// because the two buffered frames finish loading independently.
424421
const loadedDocsRef = useRef(new Set())
425-
// version -> last immersive intent (bool) that frame declared. Recorded for
426-
// EVERY frame, including a hidden incoming one whose real-time immersive post
422+
// version -> last immersive request ({ value, mode }) that frame declared.
423+
// Recorded for every frame, including a hidden incoming one whose real-time post
427424
// is withheld (only the visible frame drives chrome live). On a swap we replay
428425
// the promoted frame's recorded intent so an immersive game stays immersive
429426
// across a rebuild without a chrome flash.
@@ -735,7 +732,7 @@ const AppCanvas = forwardRef(function AppCanvas({
735732
// from the verified source, never the payload — but the mode is a
736733
// harmless presentation hint the frame may choose.
737734
const mode = msg.mode === 'bar' ? 'bar' : 'full'
738-
frameImmersiveRef.current.set(srcVersion, value)
735+
frameImmersiveRef.current.set(srcVersion, { value, mode })
739736
if (srcVersion === liveVersionRef.current && activeRef.current) {
740737
onImmersive?.(appId, value, mode)
741738
}
@@ -986,13 +983,14 @@ const AppCanvas = forwardRef(function AppCanvas({
986983
useEffect(() => {
987984
if (!appId) return
988985
const current = { appId, liveVersion: swap.liveVersion, active }
986+
const intent = frameImmersiveRef.current.get(swap.liveVersion)
989987
const value = immersiveLifecycleValue(
990988
immersiveLifecycleRef.current,
991989
current,
992-
frameImmersiveRef.current.get(swap.liveVersion),
990+
intent?.value,
993991
)
994992
immersiveLifecycleRef.current = current
995-
if (value !== null) onImmersive?.(appId, value)
993+
if (value !== null) onImmersive?.(appId, value, intent?.mode)
996994
}, [appId, swap.liveVersion, active, onImmersive])
997995

998996
// Unmount/eviction is a hard release even when no active-state render landed.
@@ -1066,9 +1064,9 @@ const AppCanvas = forwardRef(function AppCanvas({
10661064
// pad away from the notch/home-indicator. env(safe-area-inset-*) reads 0
10671065
// inside the sandboxed iframe, so the shell reads the real values off a
10681066
// probe element and posts them; the frame applies them to :root as
1069-
// --mobius-safe-*. Only non-zero while THIS app is immersive — a windowed
1070-
// app's chrome already owns the inset padding, so it must receive zeros and
1071-
// not double-pad. Sent on iframe load (sendInsets in onLoad), whenever the
1067+
// --mobius-safe-*. Only non-zero in full-bleed mode. Windowed and
1068+
// bar-collapsed apps keep shell-owned inset padding and receive zeros, so they
1069+
// cannot double-pad. Sent on iframe load (sendInsets in onLoad), whenever the
10721070
// immersive verdict flips, and on resize/orientationchange while immersive
10731071
// (a rotation moves the cutout, so the cached insets would otherwise go
10741072
// stale — see the geometry-change effect below).
@@ -1077,22 +1075,23 @@ const AppCanvas = forwardRef(function AppCanvas({
10771075
// windowed frame whose chrome already owns the inset padding also gets zeros
10781076
// so it can't double-pad.
10791077
function sendInsets(v) {
1080-
const insets = (v === liveVersionRef.current && immersive) ? readDeviceInsets() : zeroInsets()
1078+
const insets = (v === liveVersionRef.current && immersiveMode === 'full')
1079+
? readDeviceInsets()
1080+
: zeroInsets()
10811081
postToFrame(v, { type: 'moebius:frame-insets', insets })
10821082
}
10831083

10841084
// Echo the shell's APPLIED immersive verdict for this app so the runtime's
10851085
// window.mobius.immersive helper stays authoritative — toggle()/`.hidden`
10861086
// must reflect a release the shell made on its own (the floating exit button,
10871087
// or an app switch that dropped the lease), not just the app's last request.
1088-
// The `immersive` prop is exactly that applied verdict (active canvas AND this
1089-
// app holds the lease). Only the live frame is the immersive holder, so a
1088+
// `immersiveMode` is exactly that applied verdict (active canvas AND this app
1089+
// holds the lease). Only the live frame is the immersive holder, so a
10901090
// hidden/incoming buffered frame always learns `false`.
10911091
function sendImmersiveState(v) {
10921092
postToFrame(v, {
10931093
type: 'moebius:immersive-state',
1094-
// Either flavor of hidden bar counts as "hidden" for the app helper.
1095-
value: v === liveVersionRef.current ? (immersive || barCollapsed) : false,
1094+
value: v === liveVersionRef.current && immersiveMode != null,
10961095
})
10971096
}
10981097

@@ -1151,7 +1150,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11511150
if (loadedDocsRef.current.has(v)) sendInsets(v)
11521151
}
11531152
// eslint-disable-next-line react-hooks/exhaustive-deps
1154-
}, [immersive, swap.liveVersion])
1153+
}, [immersiveMode, swap.liveVersion])
11551154

11561155
// Keep the app's window.mobius.immersive helper in sync with the shell's
11571156
// applied verdict (see sendImmersiveState). Same triggers as the insets echo.
@@ -1160,7 +1159,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11601159
if (loadedDocsRef.current.has(v)) sendImmersiveState(v)
11611160
}
11621161
// eslint-disable-next-line react-hooks/exhaustive-deps
1163-
}, [immersive, barCollapsed, swap.liveVersion])
1162+
}, [immersiveMode, swap.liveVersion])
11641163

11651164
// Re-forward insets on viewport geometry change WHILE immersive. Rotating the
11661165
// device or a window resize moves the notch/home-indicator (landscape puts
@@ -1171,7 +1170,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11711170
// listener is only attached while immersive (and torn down on exit). The
11721171
// probe element resolves the fresh env() values after layout settles.
11731172
useEffect(() => {
1174-
if (!immersive) return
1173+
if (immersiveMode !== 'full') return
11751174
function onGeometryChange() { sendInsets(liveVersionRef.current) }
11761175
window.addEventListener('resize', onGeometryChange)
11771176
window.addEventListener('orientationchange', onGeometryChange)
@@ -1180,7 +1179,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11801179
window.removeEventListener('orientationchange', onGeometryChange)
11811180
}
11821181
// eslint-disable-next-line react-hooks/exhaustive-deps
1183-
}, [immersive])
1182+
}, [immersiveMode])
11841183

11851184
if (!appId) {
11861185
return (

frontend/src/components/Shell/Shell.jsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,8 @@ export default function Shell() {
318318
'--desktop-sidebar-width': `${desktopSidebarWidth}px`,
319319
'--shell-tabstrip-height': `${paneModel.STRIP_H}px`,
320320
}), [desktopSidebarWidth])
321-
// Immersive mode (moebius:immersive, .pm/128). The state is the id of the app
322-
// holding an immersive request (or null); it's APPLIED — bar hidden, canvas
321+
// Immersive mode (moebius:immersive, .pm/128). One state value owns the app
322+
// id and requested mode; it's APPLIED — bar hidden, canvas
323323
// full-viewport — only while that app is the active canvas of the FOCUSED
324324
// pane, so switching to chat/settings/another app restores chrome
325325
// automatically and switching back re-enters without a re-post. The request
@@ -328,13 +328,9 @@ export default function Shell() {
328328
// lives there. Declared here (before the content-visibility derivation) so
329329
// immersive can solo its pane over the whole workspace (§4/§9). Full contract:
330330
// lib/immersive.js.
331-
const [immersiveAppId, dispatchImmersive] = useReducer(immersiveReducer, null)
332-
// Which flavor of hidden-bar the current holder asked for: 'full' (games —
333-
// full-bleed under the notch) or 'bar' (general apps — hide the toolbar but
334-
// keep the status-bar strip, so the app looks like a standalone PWA). Only a
335-
// real-time request carries a mode; a lifecycle replay (in-place app update)
336-
// omits it and leaves the last mode intact.
337-
const [immersiveMode, setImmersiveMode] = useState('full')
331+
const [immersiveRequest, dispatchImmersive] = useReducer(immersiveReducer, null)
332+
const immersiveAppId = immersiveRequest?.appId ?? null
333+
const immersiveMode = immersiveRequest?.mode ?? 'full'
338334
const [nowPlaying, setNowPlaying] = useState(null)
339335
const mediaSessionOwnerRef = useRef(null)
340336
if (!mediaSessionOwnerRef.current) {
@@ -348,8 +344,7 @@ export default function Shell() {
348344
}, [])
349345
// Stable identity — AppCanvas's message-listener effect depends on it.
350346
const handleImmersive = useCallback((appId, value, mode) => {
351-
dispatchImmersive({ type: 'request', appId, value })
352-
if (value && (mode === 'bar' || mode === 'full')) setImmersiveMode(mode)
347+
dispatchImmersive({ type: 'request', appId, value, mode })
353348
}, [])
354349
// Immersive is a temporary overlay lease, independent of the durable builder /
355350
// single worlds. A verified request from the focused app may therefore solo
@@ -358,7 +353,7 @@ export default function Shell() {
358353
// Settings keeps its builder invariant because isImmersiveActive additionally
359354
// requires the active shell view to be the requesting canvas, and AppCanvas
360355
// forwards live requests only from its focused active frame.
361-
const immersiveActive = isImmersiveActive(immersiveAppId, activeView, activeAppId)
356+
const immersiveActive = isImmersiveActive(immersiveRequest, activeView, activeAppId)
362357
useLayoutEffect(() => {
363358
if (!immersiveActive) return
364359
const drawer = document.getElementById('navigation-drawer')
@@ -3365,8 +3360,9 @@ export default function Shell() {
33653360
offlineCapable={!!app?.offline_capable}
33663361
capabilityContract={app?.capability_contract || null}
33673362
pendingIntent={appIntents[String(id)] || null}
3368-
immersive={immersiveActive && immersiveMode === 'full' && String(immersiveAppId) === String(id)}
3369-
barCollapsed={immersiveActive && immersiveMode === 'bar' && String(immersiveAppId) === String(id)}
3363+
immersiveMode={immersiveActive && String(immersiveAppId) === String(id)
3364+
? immersiveMode
3365+
: null}
33703366
onNavPush={appNavPush}
33713367
onNavPop={appNavPop}
33723368
onNavReset={appNavReset}

frontend/src/lib/__tests__/immersive.test.js

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,48 +8,66 @@ import {
88
} from '../immersive.js'
99

1010
test('request value:true grants the immersive slot to the app', () => {
11-
assert.equal(immersiveReducer(null, { type: 'request', appId: 7, value: true }), 7)
11+
assert.deepEqual(
12+
immersiveReducer(null, { type: 'request', appId: 7, value: true }),
13+
{ appId: 7, mode: 'full' },
14+
)
1215
})
1316

1417
test('request value:false from the holder releases the slot', () => {
15-
assert.equal(immersiveReducer(7, { type: 'request', appId: 7, value: false }), null)
18+
assert.equal(immersiveReducer(
19+
{ appId: 7, mode: 'full' },
20+
{ type: 'request', appId: 7, value: false },
21+
), null)
1622
})
1723

1824
test('request value:false from a non-holder leaves the slot alone', () => {
1925
// A hidden cached iframe being evicted (or posting its own cleanup)
2026
// must not strip another app's immersive request.
21-
assert.equal(immersiveReducer(7, { type: 'request', appId: 3, value: false }), 7)
27+
const request = { appId: 7, mode: 'bar' }
28+
assert.equal(
29+
immersiveReducer(request, { type: 'request', appId: 3, value: false }),
30+
request,
31+
)
2232
})
2333

24-
test('a later request from another app wins the slot', () => {
25-
assert.equal(immersiveReducer(7, { type: 'request', appId: 3, value: true }), 3)
34+
test('a later request replaces owner and mode atomically', () => {
35+
assert.deepEqual(immersiveReducer(
36+
{ appId: 7, mode: 'full' },
37+
{ type: 'request', appId: 3, value: true, mode: 'bar' },
38+
), { appId: 3, mode: 'bar' })
2639
})
2740

2841
test('release tolerates numeric/string id mismatch', () => {
2942
// Shell passes numeric ids from /api/apps; some paths stringify.
30-
assert.equal(immersiveReducer(7, { type: 'request', appId: '7', value: false }), null)
43+
assert.equal(immersiveReducer(
44+
{ appId: 7, mode: 'full' },
45+
{ type: 'request', appId: '7', value: false },
46+
), null)
3147
})
3248

3349
test('exit (the shell button) always clears, whoever holds it', () => {
34-
assert.equal(immersiveReducer(7, { type: 'exit' }), null)
50+
assert.equal(immersiveReducer({ appId: 7, mode: 'bar' }, { type: 'exit' }), null)
3551
assert.equal(immersiveReducer(null, { type: 'exit' }), null)
3652
})
3753

3854
test('unknown actions are a no-op', () => {
39-
assert.equal(immersiveReducer(7, { type: 'bogus' }), 7)
55+
const request = { appId: 7, mode: 'full' }
56+
assert.equal(immersiveReducer(request, { type: 'bogus' }), request)
4057
})
4158

4259
test('immersive applies only on the canvas view with the holder active', () => {
43-
assert.equal(isImmersiveActive(7, 'canvas', 7), true)
60+
const request = { appId: 7, mode: 'full' }
61+
assert.equal(isImmersiveActive(request, 'canvas', 7), true)
4462
// Same request, but the user is looking elsewhere — chrome stays.
45-
assert.equal(isImmersiveActive(7, 'chat', null), false)
46-
assert.equal(isImmersiveActive(7, 'settings', null), false)
63+
assert.equal(isImmersiveActive(request, 'chat', null), false)
64+
assert.equal(isImmersiveActive(request, 'settings', null), false)
4765
// Another app is active — the holder's request is dormant, not applied.
48-
assert.equal(isImmersiveActive(7, 'canvas', 3), false)
66+
assert.equal(isImmersiveActive(request, 'canvas', 3), false)
4967
})
5068

5169
test('immersive application tolerates numeric/string id mismatch', () => {
52-
assert.equal(isImmersiveActive('7', 'canvas', 7), true)
70+
assert.equal(isImmersiveActive({ appId: '7', mode: 'full' }, 'canvas', 7), true)
5371
})
5472

5573
test('no holder means no immersive regardless of view', () => {

frontend/src/lib/__tests__/immersiveRuntime.test.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ test('holdToToggle is idempotent and cleanup restores the target', () => {
4444
})
4545

4646
cleanup()
47-
assert.equal(element.__mobiusHold, undefined)
4847
assert.deepEqual(element.style, before)
4948
for (const listeners of element.listeners.values()) assert.equal(listeners.length, 0)
49+
50+
const rewired = immersive.holdToToggle(element)
51+
assert.notEqual(rewired, cleanup)
52+
assert.equal(element.listeners.get('pointerdown').length, 1)
53+
rewired()
5054
})

frontend/src/lib/immersive.js

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
// that VERIFIED event.source === its own iframe's contentWindow — never from
1010
// the message payload — so a frame can only toggle immersive for itself.
1111
//
12-
// The state is the id of the app that currently HOLDS an immersive request,
13-
// or null. Holding a request is not the same as immersive being APPLIED:
14-
// application additionally requires that app to be the active canvas (see
15-
// isImmersiveActive).
12+
// The state is the app's complete request ({ appId, mode }) or null. Keeping
13+
// ownership and presentation together prevents a mode from leaking from a
14+
// previous holder. Holding a request is not the same as immersive being
15+
// APPLIED: application additionally requires that app to be the active canvas
16+
// (see isImmersiveActive).
1617
//
1718
// Immersive intent is deliberately SESSIONAL, not sticky navigation state.
1819
// Leaving an app releases its request, and returning does not resurrect a
@@ -22,7 +23,7 @@
2223
// over merely because its cached iframe stayed mounted. A live frame promotion
2324
// is the one replay boundary; AppCanvas owns that distinction below.
2425

25-
export function immersiveReducer(immersiveAppId, action) {
26+
export function immersiveReducer(request, action) {
2627
switch (action.type) {
2728
case 'request':
2829
// value:true grants the requesting app the immersive slot (last
@@ -32,26 +33,34 @@ export function immersiveReducer(immersiveAppId, action) {
3233
// cleanup-post and on iframe teardown (unmount / eviction / version
3334
// remount), so "app switch or unmount always restores" holds even
3435
// though tearing down an iframe never runs the app's own effects.
35-
if (action.value) return action.appId
36-
return sameApp(immersiveAppId, action.appId) ? null : immersiveAppId
36+
if (action.value) {
37+
const next = {
38+
appId: action.appId,
39+
mode: action.mode === 'bar' ? 'bar' : 'full',
40+
}
41+
return sameApp(request?.appId, next.appId) && request.mode === next.mode
42+
? request
43+
: next
44+
}
45+
return sameApp(request?.appId, action.appId) ? null : request
3746
case 'exit':
3847
// The shell's floating exit button — the user always wins. The app
3948
// is NOT consulted; it only re-enters immersive by posting again
4049
// (which a mounted app won't do until it remounts).
4150
return null
4251
default:
43-
return immersiveAppId
52+
return request
4453
}
4554
}
4655

4756
// Immersive is applied only while the requesting app is what the user is
4857
// actually looking at. Everything else — chat, settings, another app —
4958
// keeps normal chrome, which is what makes app-switch restoration
5059
// automatic: no event needs to fire, the condition just stops holding.
51-
export function isImmersiveActive(immersiveAppId, activeView, activeAppId) {
52-
return immersiveAppId != null
60+
export function isImmersiveActive(request, activeView, activeAppId) {
61+
return request?.appId != null
5362
&& activeView === 'canvas'
54-
&& sameApp(immersiveAppId, activeAppId)
63+
&& sameApp(request.appId, activeAppId)
5564
}
5665

5766
/**

0 commit comments

Comments
 (0)