Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion apps/web/src/lib/mobile/MobileRuntimeProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { setNavigationAuthority, useNavigationStore } from "@openmapx/core";
import { MOBILE_PROTOCOL_MAX, MOBILE_PROTOCOL_MIN } from "@openmapx/core/navigation";
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MobileRuntimeProvider } from "./MobileRuntimeProvider";
import { CHANNEL_GLOBAL } from "./mobileShellEnvironment";
import { useMobileRuntime, useNavigationModeAvailable } from "./useMobileRuntime";
Expand Down Expand Up @@ -354,6 +354,41 @@ describe("MobileRuntimeProvider store integration", () => {
expect(state.progress).toEqual({ alongMeters: 100 });
});

it("updates the store when a snapshot arrives, never while React renders", async () => {
// Applying the store inside a state updater ran it during the provider's
// render, updating every map layer subscribed to the store mid-render.
function RouteSubscriber() {
const revision = useNavigationStore((s) => s.nativeRevision);
return <span data-testid="store-revision">{revision ?? "none"}</span>;
}
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
const shell = shellScope();
render(
<MobileRuntimeProvider webBuildId="web-build-1" scope={shell.scope}>
<Probe />
<RouteSubscriber />
</MobileRuntimeProvider>,
);
deliverHello(shell);
await waitFor(() => expect(text("state")).toBe("native-compatible"));

shell.deliver(snapshot());
shell.deliver(
snapshot({ type: "progress", revision: 5, kind: undefined, progress: { alongMeters: 200 } }),
);
shell.deliver(
snapshot({ type: "progress", revision: 6, kind: undefined, progress: { alongMeters: 300 } }),
);

await waitFor(() => expect(text("store-revision")).toBe("6"));
expect(text("revision")).toBe("6");
const renderPhaseUpdates = errors.mock.calls.filter((call) =>
String(call[0]).includes("while rendering a different component"),
);
errors.mockRestore();
expect(renderPhaseUpdates).toEqual([]);
});

it("advances the store by one revision on a delta", async () => {
const shell = shellScope();
mount(shell.scope);
Expand Down
62 changes: 36 additions & 26 deletions apps/web/src/lib/mobile/MobileRuntimeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { setNavigationAuthority, useNavigationStore } from "@openmapx/core";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
Expand Down Expand Up @@ -85,7 +86,16 @@ export function MobileRuntimeProvider({ children, webBuildId, scope }: MobileRun
const inShell = useMemo(() => isInstalledShell(scope ?? globalThis), [scope]);
const [state, setState] = useState<MobileRuntimeState>(inShell ? "negotiating" : "browser");
const [handshake, setHandshake] = useState<HandshakeResult | null>(null);
const [readModel, setReadModel] = useState<NativeReadModel | null>(null);
const [readModel, setReadModelState] = useState<NativeReadModel | null>(null);
// The read model as of the last message, for the message handler. Bridge
// messages are applied as they arrive, never inside a state updater: React
// runs updaters while rendering, and applying the navigation store there
// updated every component subscribed to it in the middle of a render.
const readModelRef = useRef<NativeReadModel | null>(null);
const setReadModel = useCallback((next: NativeReadModel | null) => {
readModelRef.current = next;
setReadModelState(next);
}, []);
const clientRef = useRef<BridgeClient | null>(null);
const [commands, setCommands] = useState<NativeNavigationCommands | null>(null);

Expand Down Expand Up @@ -117,28 +127,26 @@ export function MobileRuntimeProvider({ children, webBuildId, scope }: MobileRun
if (message.type === "snapshot.update") {
const incoming = envelopeOf((message.payload as { snapshot?: unknown }).snapshot);
if (!incoming) return;
setReadModel((current) => {
const outcome = applyNativeSnapshot(current, incoming);
if (!outcome.ok) {
// A gap or a changed route means the page is behind; ask for the
// whole thing rather than render an interpolation.
if (outcome.reason === "need-full-snapshot") void nativeCommands.requestSnapshot();
return current;
}
// The store re-checks the step against what it actually rendered,
// which is what catches a batched render dropping one in between.
const projection = projectionOf(outcome.model, incoming.type);
const store = useNavigationStore.getState();
const applied =
incoming.type === "full"
? store.applyNativeFullSnapshot(projection)
: store.applyNativeDelta(projection);
if (applied === "needs-full-snapshot") {
void nativeCommands.requestSnapshot();
return current;
}
return outcome.model;
});
const outcome = applyNativeSnapshot(readModelRef.current, incoming);
if (!outcome.ok) {
// A gap or a changed route means the page is behind; ask for the
// whole thing rather than render an interpolation.
if (outcome.reason === "need-full-snapshot") void nativeCommands.requestSnapshot();
return;
}
// The store re-checks the step against what it actually rendered,
// which is what catches a batched render dropping one in between.
const projection = projectionOf(outcome.model, incoming.type);
const store = useNavigationStore.getState();
const applied =
incoming.type === "full"
? store.applyNativeFullSnapshot(projection)
: store.applyNativeDelta(projection);
if (applied === "needs-full-snapshot") {
void nativeCommands.requestSnapshot();
return;
}
setReadModel(outcome.model);
return;
}

Expand Down Expand Up @@ -169,11 +177,13 @@ export function MobileRuntimeProvider({ children, webBuildId, scope }: MobileRun
.getState()
.applyNativeEvent({ eventId, type: String(message.type) });
if (outcome !== "applied") return;
setReadModel((current) => (current ? rememberEvent(current, eventId) : current));
const current = readModelRef.current;
if (current) setReadModel(rememberEvent(current, eventId));
// Acknowledged immediately: the durable copy lives natively, and leaving
// it unacknowledged would replay it on every reconnect.
void nativeCommands.acknowledgeEvents([eventId]);
setReadModel((current) => (current ? forgetEvents(current, [eventId]) : current));
const remembered = readModelRef.current;
if (remembered) setReadModel(forgetEvents(remembered, [eventId]));
}
});

Expand All @@ -200,7 +210,7 @@ export function MobileRuntimeProvider({ children, webBuildId, scope }: MobileRun
clientRef.current = null;
setCommands(null);
};
}, [inShell, webBuildId, scope]);
}, [inShell, webBuildId, scope, setReadModel]);

// Reconcile whenever the page could have missed something: returning to the
// foreground, or coming back online. Native state wins in both directions —
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/lib/mobile/useForegroundLocation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

const runtime = vi.hoisted(() => ({
value: {
browserAuthority: false,
state: "native-compatible",
client: {},
readModel: { revision: 1 },
handshake: null,
} as Record<string, unknown>,
}));

vi.mock("./MobileRuntimeProvider", () => ({
useMobileRuntimeContext: () => runtime.value,
}));

const { useForegroundLocation } = await import("./useForegroundLocation");

describe("useForegroundLocation", () => {
it("keeps one function while native snapshots arrive", () => {
// The map is built from this function: a new one on every snapshot tore the
// map down and rebuilt it every few seconds during native navigation.
const view = renderHook(() => useForegroundLocation());
const first = view.result.current;

runtime.value = { ...runtime.value, readModel: { revision: 2 } };
view.rerender();

expect(view.result.current).toBe(first);
});

it("changes when where it would ask for a fix changes", () => {
const view = renderHook(() => useForegroundLocation());
const first = view.result.current;

runtime.value = { ...runtime.value, browserAuthority: true, state: "browser" };
view.rerender();

expect(view.result.current).not.toBe(first);
});
});
14 changes: 9 additions & 5 deletions apps/web/src/lib/mobile/useForegroundLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,25 @@ import { useMobileRuntimeContext } from "./MobileRuntimeProvider";
* native-authority split exists to remove.
*/
export function useForegroundLocation() {
const runtime = useMobileRuntimeContext();
// Only what decides where a fix comes from. The runtime context changes with
// every native snapshot, and the map is built from this function, so
// depending on the whole context rebuilt the map every few seconds while
// navigating in the installed app.
const { browserAuthority, client, state } = useMobileRuntimeContext();
const counter = useRef(0);

return useCallback(
async (options: ForegroundLocationOptions = {}): Promise<ForegroundLocationResult> => {
if (runtime.browserAuthority) return browserForegroundFix(options);
if (!runtime.client || runtime.state !== "native-compatible") {
if (browserAuthority) return browserForegroundFix(options);
if (!client || state !== "native-compatible") {
return { status: "unavailable" };
}
counter.current += 1;
// Per-request so a slow answer to an abandoned request cannot move the map
// under someone who has since asked again.
const requestId = `fix-${counter.current}`;
return nativeForegroundFix(runtime.client, requestId, options);
return nativeForegroundFix(client, requestId, options);
},
[runtime],
[browserAuthority, client, state],
);
}
62 changes: 62 additions & 0 deletions apps/web/src/lib/navigation/useNavCamera.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,68 @@ describe("useNavCamera lifecycle", () => {
expect(replacement.state.cameraTransitions.some((t) => t.method === "jumpTo")).toBe(true);
});

/**
* The page swaps in a new map without clearing `mapReady` (a style rebuild,
* or the map being re-created), so navigation can start in the gap where
* `mapRef` is empty. `styleVersion` is what announces the new map.
*/
async function mountDuringMapReplacement() {
const frames = createFrameHarness(CLOCK_START);
vi.stubGlobal("requestAnimationFrame", frames.request);
vi.stubGlobal("cancelAnimationFrame", frames.cancel);
vi.spyOn(performance, "now").mockImplementation(() => frames.now());
mapContextTest.mapRef.current = null;
mapContextTest.mapReady = true;
startRoute();
const view = renderHook(() => useNavCamera());
await act(async () => {
await Promise.resolve();
});
const fake = createFakeMap({ zoom: 10 });
mapContextTest.mapRef.current = fake.map;
mapContextTest.styleVersion += 1;
view.rerender();
await act(async () => {
await Promise.resolve();
});
return { frames, fake, view };
}

it("draws the puck when navigation starts while the map is being replaced", async () => {
const { frames, fake } = await mountDuringMapReplacement();
applyFix(START_ALONG);
frames.flush(60, FRAME_MS);

expect(markerTest.markers[0]?.map).toBe(fake.map);
expect(fake.state.cameraTransitions.some((t) => t.method === "jumpTo")).toBe(true);
});

it("enters follow on a map that arrives after navigation started", async () => {
const { fake } = await mountDuringMapReplacement();
expect(fake.state.cameraTransitions.some((t) => t.method === "easeTo")).toBe(true);
});

it("enters follow again on a replacement map, and not on a style reload", async () => {
const harness = await mountNavCamera();
applyFix(START_ALONG);
harness.frames.flush(60, FRAME_MS);
const eases = (fake: FakeMap) =>
fake.state.cameraTransitions.filter((t) => t.method === "easeTo").length;
const before = eases(harness.fake);

// A style reload keeps the map, and the camera the user is following.
mapContextTest.styleVersion += 1;
harness.rerender();
expect(eases(harness.fake)).toBe(before);

// A new map starts from its own default camera and needs follow re-entered.
const replacement = createFakeMap({ zoom: 10 });
mapContextTest.mapRef.current = replacement.map;
mapContextTest.styleVersion += 1;
harness.rerender();
expect(eases(replacement)).toBe(1);
});

it("does not attach a marker that arrives after navigation stopped", async () => {
const frames = createFrameHarness(CLOCK_START);
vi.stubGlobal("requestAnimationFrame", frames.request);
Expand Down
Loading
Loading