From c1fa0c31cbf1082cbb8c0efe60fe485ba58ca1ca Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:23:22 +0000 Subject: [PATCH] fix(web): stop 404 signout on post-signup sync and tolerate cache-only local edits The event-runtime cutover (#2017) introduced two offline-handling bugs: - syncLocalEventsToCloud fell back to a client-generated sentinel calendar id when no server-side local calendar was found, so the POST /event 404'd with CALENDAR_NOT_FOUND. handleErrorResponse then treated that 404 as an auth failure and signed the just-signed-up user out. Now the sync only targets the real server local calendar and otherwise keeps records on-device for a later sync; a 404 on a data endpoint no longer forces sign-out (only GONE/UNAUTHORIZED do). - Editing an event whose id lives only in the react-query cache (e.g. a materialized recurring-occurrence instance) but not in IndexedDB threw "Event not found" from LocalEventRepository. replace now upserts instead of throwing, so offline edits persist rather than crashing. Generated-By: PostHog Code Task-Id: 3b0e5f4a-eb9c-4db7-8522-feeb18b17dc8 --- packages/web/src/api/util/api.util.test.ts | 24 +++++++- packages/web/src/api/util/api.util.ts | 8 ++- .../utils/sync/local-event-sync.util.test.ts | 13 ++++ .../utils/sync/local-event-sync.util.ts | 15 +++-- .../local.event.repository.test.ts | 60 ++++++++++++++----- .../repositories/local.event.repository.ts | 40 +++++++++---- 6 files changed, 124 insertions(+), 36 deletions(-) diff --git a/packages/web/src/api/util/api.util.test.ts b/packages/web/src/api/util/api.util.test.ts index 7c70317d6..62f7439cc 100644 --- a/packages/web/src/api/util/api.util.test.ts +++ b/packages/web/src/api/util/api.util.test.ts @@ -201,12 +201,32 @@ describe("handleErrorResponse", () => { ).rejects.toThrow("Google revocation handler is not configured"); }); - it("keeps skipSessionRecovery local to unauthorized responses", async () => { + it("does not sign the user out on a 404 from a data endpoint", async () => { window.history.pushState({}, "", "/day"); const signOutSpy = spyOn(session, "signOut").mockResolvedValue(undefined); const error = createApiError( { status: Status.NOT_FOUND }, - { skipSessionRecovery: true, url: "/event" }, + { url: "/event" }, + ); + + // A missing resource (e.g. syncing an event onto a not-yet-provisioned + // calendar) rethrows for the caller to handle - it must not eject the user. + await expect( + handleErrorResponse(error, { onGoogleRevoked: undefined }), + ).rejects.toBe(error); + + expect(signOutSpy).not.toHaveBeenCalled(); + signOutSpy.mockRestore(); + }); + + it("still signs the user out on an unauthorized data-endpoint response", async () => { + // Already on the calendar route, so signOut skips the (jsdom-unsupported) + // navigation and we can assert purely on the sign-out call. + window.history.pushState({}, "", "/week"); + const signOutSpy = spyOn(session, "signOut").mockResolvedValue(undefined); + const error = createApiError( + { status: Status.UNAUTHORIZED }, + { url: "/event" }, ); await expect( diff --git a/packages/web/src/api/util/api.util.ts b/packages/web/src/api/util/api.util.ts index b5b066fbd..e0487c517 100644 --- a/packages/web/src/api/util/api.util.ts +++ b/packages/web/src/api/util/api.util.ts @@ -159,11 +159,13 @@ export const handleErrorResponse = async ( const isAuthEndpoint = requestUrl?.includes("/signinup"); + // A 404 on a data endpoint means a resource is missing (e.g. syncing a + // just-created event onto a calendar the server hasn't provisioned yet), + // not that the session is invalid - so it must never force a sign-out. + // Only genuine session-level failures (GONE/UNAUTHORIZED) do. if ( !isAuthEndpoint && - (status === Status.GONE || - status === Status.NOT_FOUND || - status === Status.UNAUTHORIZED) + (status === Status.GONE || status === Status.UNAUTHORIZED) ) { await signOut(status); } else if (!isAuthEndpoint) { diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts index 3b0451b6d..1a3b7551f 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts @@ -86,4 +86,17 @@ describe("syncLocalEventsToCloud", () => { expect(createEvent).not.toHaveBeenCalled(); expect(clearAllEvents).not.toHaveBeenCalled(); }); + + it("keeps records on-device when the server has no local calendar yet, rather than posting a sentinel id", async () => { + // No local calendar in the list (e.g. it hasn't been provisioned yet). + listCalendars.mockResolvedValue([]); + getAllEvents.mockResolvedValue([createMockLocalEventRecord({}, false)]); + + await expect(syncLocalEventsToCloud()).resolves.toBe(0); + + // Never POST against a calendar the backend can't resolve (would 404), and + // never clear the store - the records stay put for a later sync. + expect(createEvent).not.toHaveBeenCalled(); + expect(clearAllEvents).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.ts b/packages/web/src/common/utils/sync/local-event-sync.util.ts index 261443a17..8610ed4e1 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.ts @@ -1,7 +1,6 @@ import { type CreateEventInput } from "@core/types/event-command.contracts"; import { CalendarApi } from "@web/calendars/calendar.api"; import { getLocalCalendar } from "@web/calendars/calendar.util"; -import { getLocalCalendarSentinelId } from "@web/calendars/local-calendar.sentinel"; import { type OfflineDataStore } from "@web/common/storage/offline-data/offline-data.store"; import { ensureOfflineDataStoreReady, @@ -63,11 +62,19 @@ export function createSyncLocalEventsToCloud({ if (recordsToSync.length > 0) { const calendars = await listCalendars(); const serverLocalCalendar = getLocalCalendar(calendars); - const serverLocalCalendarId = - serverLocalCalendar?.id ?? getLocalCalendarSentinelId(); + + // Never fall back to the client-generated sentinel calendar id: the + // backend can't resolve it and rejects the POST with CALENDAR_NOT_FOUND + // (a 404 that used to sign the just-signed-up user out). When the + // server-side local calendar isn't available yet, keep the records + // on-device - untouched, so a later sync can push them onto the real + // calendar - rather than losing them to a rejected request. + if (!serverLocalCalendar) { + return 0; + } for (const record of recordsToSync) { - await createEvent(toCreateInput(record, serverLocalCalendarId)); + await createEvent(toCreateInput(record, serverLocalCalendar.id)); } } diff --git a/packages/web/src/events/repositories/local.event.repository.test.ts b/packages/web/src/events/repositories/local.event.repository.test.ts index 463d73b63..6ebe52900 100644 --- a/packages/web/src/events/repositories/local.event.repository.test.ts +++ b/packages/web/src/events/repositories/local.event.repository.test.ts @@ -1,4 +1,5 @@ import { + type CalendarId, DateTimeSchema, type EventId, TimeZoneSchema, @@ -38,21 +39,52 @@ describe("LocalEventRepository", () => { expect(putEvent.mock.calls[0][0].isDemo).toBe(true); }); - it("throws when replacing an event that does not exist locally", async () => { + it("upserts (instead of throwing) when the edit target is absent from the store", async () => { + // The optimistic layer can resolve an edit target from the query cache + // that was never persisted to IndexedDB (e.g. a materialized recurring + // occurrence). Replacing it must not throw "Event not found". getAllEvents.mockResolvedValue([]); - await expect( - repository.replace("c".repeat(24) as EventId, { - content: { kind: "details", title: "x", description: "" }, - schedule: { - kind: "timed", - start: DateTimeSchema.parse("2026-05-05T09:00:00.000-05:00"), - end: DateTimeSchema.parse("2026-05-05T10:00:00.000-05:00"), - timeZone: TimeZoneSchema.parse("America/Chicago"), - }, - recurrence: { kind: "single" }, - scope: "this", - }), - ).rejects.toThrow(); + const id = "c".repeat(24) as EventId; + const result = await repository.replace(id, { + content: { kind: "details", title: "x", description: "" }, + schedule: { + kind: "timed", + start: DateTimeSchema.parse("2026-05-05T09:00:00.000-05:00"), + end: DateTimeSchema.parse("2026-05-05T10:00:00.000-05:00"), + timeZone: TimeZoneSchema.parse("America/Chicago"), + }, + recurrence: { kind: "single" }, + scope: "this", + }); + + expect(result.id).toBe(id); + expect(putEvent).toHaveBeenCalledTimes(1); + expect(putEvent.mock.calls[0][0]).toMatchObject({ + id, + isDemo: false, + event: { id, content: { title: "x" } }, + }); + }); + + it("preserves the input calendar when replacing a missing event that carries one", async () => { + getAllEvents.mockResolvedValue([]); + + const id = "d".repeat(24) as EventId; + const calendarId = "e".repeat(24) as EventId; + const result = await repository.replace(id, { + content: { kind: "details", title: "y", description: "" }, + calendarId: calendarId as unknown as CalendarId, + schedule: { + kind: "timed", + start: DateTimeSchema.parse("2026-05-05T09:00:00.000-05:00"), + end: DateTimeSchema.parse("2026-05-05T10:00:00.000-05:00"), + timeZone: TimeZoneSchema.parse("America/Chicago"), + }, + recurrence: { kind: "single" }, + scope: "this", + }); + + expect(result.calendarId).toBe(calendarId as unknown as CalendarId); }); }); diff --git a/packages/web/src/events/repositories/local.event.repository.ts b/packages/web/src/events/repositories/local.event.repository.ts index 238c7775d..a6b27eefd 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -6,6 +6,7 @@ import { type RecurrenceScope, type ReplaceEventInput, } from "@core/types/event-command.contracts"; +import { getLocalCalendarSentinelId } from "@web/calendars/local-calendar.sentinel"; import { getOfflineDataStore, type OfflineDataStore, @@ -41,17 +42,19 @@ export class LocalEventRepository implements EventRepository { return records.map((record) => record.event); } - private async getRecordById(id: EventId): Promise { + private async findRecordById( + id: EventId, + ): Promise { const records = await this.store.getAllEvents(); - const record = records.find((r) => r.id === id); - if (!record) { - throw new Error(`Event not found: ${id}`); - } - return record; + return records.find((r) => r.id === id); } async getById(id: EventId): Promise { - return (await this.getRecordById(id)).event; + const record = await this.findRecordById(id); + if (!record) { + throw new Error(`Event not found: ${id}`); + } + return record.event; } async create(input: CreateEventInput): Promise { @@ -77,22 +80,33 @@ export class LocalEventRepository implements EventRepository { } async replace(id: EventId, input: ReplaceEventInput): Promise { - const existingRecord = await this.getRecordById(id); - const existing = existingRecord.event; + // The optimistic layer resolves the edit target from the react-query + // cache, which can hold an event that never made it into IndexedDB - most + // commonly a materialized recurring-occurrence instance (its id differs + // from the stored series record). Rather than throwing "Event not found" + // on that mismatch, upsert: persist the edit so an offline change isn't + // lost, falling back to the local calendar when the input carries no + // calendarId of its own. + const existingRecord = await this.findRecordById(id); + const existing = existingRecord?.event; const recurrence = input.recurrence.kind === "preserve" - ? existing.recurrence + ? (existing?.recurrence ?? { kind: "single" as const }) : input.recurrence.kind === "series" ? { kind: "series" as const, rules: input.recurrence.rules } : { kind: "single" as const }; const event: Event = { - ...existing, - calendarId: input.calendarId ?? existing.calendarId, + id, + calendarId: + input.calendarId ?? + existing?.calendarId ?? + getLocalCalendarSentinelId(), content: input.content, schedule: input.schedule, recurrence, + createdAt: existing?.createdAt ?? nowDateTime(), updatedAt: nowDateTime(), }; @@ -100,7 +114,7 @@ export class LocalEventRepository implements EventRepository { version: 2, id, event, - isDemo: existingRecord.isDemo, + isDemo: existingRecord?.isDemo ?? false, }; await this.store.putEvent(record); return event;