mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(mobile): keep a painted frame under the page until its first paint The shell tore its own frame down the moment a generation was on screen (`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws nothing until its document paints. What showed for the whole of the page's boot was the surface behind it with nothing on it: 1.42 s on a cached generation, against a one-frame budget. The page is the only thing that knows when it has a frame, so it says so. It declares `painted` in `ready.reports` and posts the notify after the browser has painted its first commit; the shell holds the same neutral frame it was already painting while it opened the generation, then fades it out. The wait is bounded by the declaration and never by a timer: a generation served by an older desktop declares nothing and is uncovered on `ready`, which is what every shell did before this. iOS painted white rather than nothing: a WKWebView is opaque by default, so the shell's own surface never showed through. It is now transparent, as the Android view already was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the cover through the compositor handover The page reports the paint its own renderer made; putting that on the app's surface costs another frame or two. A linear fade from the report left two frames of bare surface between the two on an emulator, which is the hole the cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity across that handover: five reopens now show 0-21 ms of bare surface against 102-2043 ms on the build without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): negotiate the paint report in both directions The page posted `painted` whatever shell it met, and `notify` is a closed union: every shell installed before this answered it with an error frame, once per mount. The shell now advertises the name in `init.accepts` beside the param clear and the client identity, and the page posts only when it was advertised. The declaration in `ready.reports` stays unconditional, because it is an optional field an older reader strips rather than a new opcode, and because the first `ready` — the only one that matters for the first paint — is sent before any `init` has arrived. The accepts list moves into `bridge-init-frame.ts` beside the grants, which is the module that builds the frame carrying it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cover's colour instead of asserting its shape Two gate findings on the round-two head. The cover test reached the background through a cast of the style prop; it now reads it through a checked narrowing, so the test proves the shape it depends on rather than declaring it. `use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args gained `onPagePainted`: its harness is a literal, so a new required handler is a missing property. The probe now counts paints and one case spends the counter, which is what a handler wired only to satisfy a type would not do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the cached-generation opening out of the reducer `mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused- update work and the paint handling both grew it. What comes out is one thing — putting a generation already on disk on screen, and deciding whether this route is one that bundle carries. It is the reducer's cache path and its refused- update path both, and it was already three functions sitting together. `step` goes into a module of its own because the two now share it; a copy in each would be two spellings of one transition, and exporting it from either would point the dependency the wrong way. No behaviour moves: the reducer's table tests are unchanged and the page closure is unchanged at 4,211, since neither new module is reachable from a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the previous document's paint when a new one starts A document that replaced a painted one inside the same mount inherited its `pagePainted`, so the cover lifted before the replacement had drawn anything. The native view already reports `loading`; the screen dropped it. It now reaches the reducer as `document-started` and clears the page document state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the declaration case call the frame policy The case compared the name to itself and never called `shellPageFrame`, so it passed for a policy that ignored the declaration entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the page's frame from the route screen, not the router Every route screen is behind `import()`, so the wrapper above expo-router commits with a suspense fallback while the chunk is still arriving. The paint report hung there, which uncovered the shell's view over an empty body on a cold chunk. It now hangs on the screen the manifest resolves, layouts excluded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): retire the readiness wait a replaced document armed `document-started` cleared the page document state and left the flow alone, so the previous document's readiness deadline passed the flow check, read `pageReady` as false and failed a session whose replacement was still loading. The flow moves with the document, for the reason `remounted` already moves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a departing route screen take its paint report back The report waits two frames, and nothing cancelled the second one, so a screen unmounted in between still told the shell to uncover. The reporter now answers with a take-back the wrapper returns as its cleanup, and the once-per-document latch frees only when a report was cancelled before it landed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the screen that arrived take over a frame still owed A screen committing inside the two frames an earlier one was owed found the latch taken and reported nothing; the earlier screen then freed that latch on its way out and nobody was left to lift the cover. The newest commit now supersedes the pending report, and only a posted one spends the latch. Covers the redirect window with a render check against the pr route, whose target chunk is held open while the document sits on the hub's fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the take-over case turn on the take-over The case cancelled the first screen's frame through the cleanup path, so it passed with the take-over deleted. It now leaves that screen mounted and reads the clock: the frame after the replacement commits is the replacement's first, not the one the screen behind it was still owed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { colors } from '../theme/mobile-theme'
|
|
|
|
const fades = vi.hoisted(() => ({ stops: 0, finished: true }))
|
|
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
Easing: { in: (fn: unknown) => fn, quad: 'quad' },
|
|
Animated: {
|
|
View: 'Animated.View',
|
|
Value: class {
|
|
setValue(): void {}
|
|
},
|
|
timing: () => ({
|
|
start: (done?: (result: { finished: boolean }) => void) => {
|
|
done?.({ finished: fades.finished })
|
|
},
|
|
stop: () => {
|
|
fades.stops += 1
|
|
}
|
|
})
|
|
},
|
|
StyleSheet: {
|
|
create: (styles: unknown) => styles,
|
|
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
|
|
},
|
|
Text: 'Text'
|
|
}))
|
|
|
|
const { ShellPageCover } = await import('./ShellWaitingFrame')
|
|
|
|
function render(visible: boolean): ReactTestRenderer {
|
|
let tree: ReactTestRenderer | null = null
|
|
act(() => {
|
|
tree = create(createElement(ShellPageCover, { label: 'Opening workspace', visible }))
|
|
})
|
|
if (tree === null) {
|
|
throw new Error('the cover never rendered')
|
|
}
|
|
return tree
|
|
}
|
|
|
|
function cover(tree: ReactTestRenderer) {
|
|
return tree.root.findAllByProps({ testID: 'mobile-web-shell-cover' })[0] ?? null
|
|
}
|
|
|
|
/** The colour the cover fills with, read out of its style prop rather than assumed of its shape. */
|
|
function coverBackground(tree: ReactTestRenderer): unknown {
|
|
const style: unknown = cover(tree)?.props.style
|
|
const base: unknown = Array.isArray(style) ? style[0] : null
|
|
return typeof base === 'object' && base !== null && 'backgroundColor' in base
|
|
? base.backgroundColor
|
|
: null
|
|
}
|
|
|
|
describe('the frame the shell keeps over an unpainted page', () => {
|
|
it('is up while the page has not painted', () => {
|
|
const tree = render(true)
|
|
expect(cover(tree)).not.toBeNull()
|
|
})
|
|
|
|
it('paints the app surface and never black, so an empty view is never a hole', () => {
|
|
// The whole defect in one assertion: what shows while the WebView draws nothing is this.
|
|
const background = coverBackground(render(true))
|
|
expect(background).toBe(colors.bgBase)
|
|
expect(background).not.toBe('#000000')
|
|
})
|
|
|
|
it('never takes a touch, so a report that never lands leaves a usable page under it', () => {
|
|
expect(cover(render(true))?.props.pointerEvents).toBe('none')
|
|
})
|
|
|
|
it('goes once the page reports a frame', () => {
|
|
const tree = render(true)
|
|
act(() => {
|
|
tree.update(createElement(ShellPageCover, { label: 'Opening workspace', visible: false }))
|
|
})
|
|
expect(cover(tree)).toBeNull()
|
|
})
|
|
|
|
it('stays out of the way when a fade is cut short rather than staying opaque', () => {
|
|
// A platform that stops the fade reports `finished: false`; the cover keeps its element and
|
|
// its `pointerEvents: none`, which is a transparent inert layer rather than an opaque one.
|
|
fades.finished = false
|
|
const tree = render(true)
|
|
act(() => {
|
|
tree.update(createElement(ShellPageCover, { label: 'Opening workspace', visible: false }))
|
|
})
|
|
expect(cover(tree)?.props.pointerEvents).toBe('none')
|
|
fades.finished = true
|
|
})
|
|
|
|
it('stops a fade in flight when the view unmounts', () => {
|
|
const before = fades.stops
|
|
const tree = render(false)
|
|
act(() => {
|
|
tree.unmount()
|
|
})
|
|
expect(fades.stops).toBeGreaterThan(before)
|
|
})
|
|
})
|