Skip to content

fix(react): useOffline auto-syncs mutations queued while already online - #6860

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6818-useoffline-stale-queue-guard
Aug 30, 2026
Merged

fix(react): useOffline auto-syncs mutations queued while already online#6860
os-sam merged 2 commits into
mainfrom
claude/issue-6818-useoffline-stale-queue-guard

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#6818

The intent question, settled first

Triage required establishing whether the early return was intended before
changing behaviour, and ruled that the eslint-disable comment ("Only trigger
on isOnline changes, not on every queue change") is evidence about timer
restarts
and about nothing else. Measured on this base, the early return was
not a decision:

  • The producer and the consumer disagree, in code.queueMutation has never
    been conditional on being offline — it appends whenever enabled is true. A
    queue that accepts entries while online but whose only drain trigger is an
    isOnline / enabled transition is an incoherent pair, not a design. If the
    queue were meant to fill only while offline, queue.length > 0 && isOnline
    would be reachable only through a reconnect and the stale guard would be
    harmless; it is harmful precisely because the producer says otherwise.
  • The history says the same. Only two commits ever touched this effect.
    bdf6f9d51 introduced it with the narrow deps and the suppression already in
    place, and the day-one tests it shipped with queue mutations while online
    and then drain them with an explicit sync() — so the online-queue path was
    exercised from the start, just never auto-synced. a1d01ca6a ("address code
    review feedback — ... auto-sync delay") changed the delay from
    retryInterval to a fixed 100ms and left the deps and the guard untouched: no
    statement about either.

⇒ the code branch of triage's fork, not the documentation branch.

What changed

Point 1 — the stale guard. The effect is keyed on the boolean
queue.length > 0, never on queue and never on queue.length. The
suppression's real reason is preserved intact: a boolean does not change when a
second mutation lands, so a mutation queued while the 100ms stabilization timer
is already armed does not re-run the effect and does not restart the timer.
What the suppression never justified — evaluating the emptiness guard against a
snapshot from whenever isOnline last changed — is what moved.

Point 2 — the two halves of one call.sync read batchSize through a ref
(newest) and queue from its own closure (a snapshot), and the auto-sync effect
retains such a closure by design. The queue now reaches sync through the same
commit-phase mirror the sync config already uses (useInsertionEffect, the
shape objectui#6797 established here), so both halves are the newest committed
values.

The eslint-disable is deleted, not reworded. That is a consequence rather
than a separate edit: mirroring the queue takes queue out of sync's
dependency list, so sync is keyed [enabled] and is stable across queued
mutations — which means it can be named in the effect's dependency array
without costing a timer restart. The array is [isOnline, enabled, hasPendingMutations, sync] and is genuinely exhaustive. Proven, not asserted:
dropping sync from it makes react-hooks/exhaustive-deps report "React Hook
useEffect has a missing dependency: 'sync'"
at useOffline.ts:392, so the rule
is live on this effect and its silence with the full array is a reading.

No option or parameter is added: OfflineConfig and OfflineResult are
untouched, and the exported symbol lines of the file are byte-identical to the
base. This is not a public-surface widening.

Verification

A green suite proves nothing here — nothing in this repo reaches the sync queue
— so every pin drives a real mutation through it.
packages/react/src/hooks/__tests__/useOffline.autoSyncWhileOnline.test.tsx:

pinassertson the base
1a mutation queued while already online drainsred (stays at 1)
2a 2nd mutation at t=60 does not re-arm: both drain by t=105red (stays at 2)
3the offline to online transition still auto-syncsgreen — the regression guard
4a retainedsync batches the newest queue, not its snapshotred (drains 1 of 2)

Pin 2 is the discriminating one: keyed on the queue (or on queue.length) the
effect would re-arm at t=60 for t=160, so at t=105 nothing would have drained.

Ablation — implementation committed first, then useOffline.ts replaced
with its 1e14d70 content. Mutation confirmed on disk before measuring (blob
4a6fc50 to 49fe3a3; hasPendingMutations 0 occurrences, the eslint-disable
back to 1). Result: Tests 3 failed | 5 passed (8) — pins 1, 2 and 4 red, pin 3
and all four objectui#6797 pins green. Restore proven, not assumed:
git checkout HEAD -- ABS_PATH, blob back to 4a6fc50, git diff HEAD empty.
No rebuild leg applies — both test files import ../useOffline by relative
source path, so no dist is on the resolution path.

Gates, all at the final head 2e8e724 (union re-run after the last commit):

  • pnpm exec vitest run --maxWorkers=2 packages/react/ packages/app-shell/src/layout/__tests__/ packages/app-shell/src/hooks/__tests__/sharedInboxFeed.twoSurfaces.test.tsxTest Files 97 passed (97) · Tests 1033 passed (1033)
  • pnpm --filter @object-ui/react type-check — clean (tsc --noEmit && tsc -p tsconfig.test.json). Both edited test files are inside that program: --listFiles reports 1 occurrence each, so "typecheck is clean" is a statement about them.
  • pnpm --filter @object-ui/react build — clean
  • check:control-bytescheck-control-bytes: OK (scanned 5710 tracked text file(s)); plus a raw scan over the diff, no hits
  • check:changeset-no-majorNo changeset declares a major bump.
  • check:vi-mock-specifiers, check:doc-fences, check:phantom-deps, check:self-import, check:spec-symbols, check:skills-paths — all OK

check:readme-exports is NOT MEASURED, not green and not red: it exits 1
with the population COLLAPSED -- this run proves nothing · packagesRead: found 5, floor is 25, because it needs a repo-wide pnpm build and this worktree
built only @object-ui/react and its dependency closure. It is CI-owned. It
also cannot move on this diff: the file's exported symbol lines are identical to
the base.

Lint is a declared narrowing, with its three measurements. Repo-wide
turbo run lint is CI's run; here eslint ran on the diff. (1) The population
comes from eslint's own config, not from a guess — the changeset .md is
reported File ignored because no matching configuration was supplied, so the
population is the 3 .ts/.tsx files. (2) The count is read from
--format json: 3 files, 0 errors, 1 warning, the same pre-existing
react-hooks/set-state-in-effect on an untouched line that the base carries.
(3) Untouched files cannot move: eslint.config.js sets no parserOptions.project,
no projectService and no recommendedTypeChecked, so no linting here is
type-aware and a change to these three files cannot alter any verdict elsewhere.

Premise checks (the dispatch asked for these to be re-measured)

  • The card's line numbers survived, but not for the reason they might look
    like.
    The card cited useOffline.ts:343-351 on a77a00c2c; PR fix(react): stop writing config refs during render in useETagCache, useGlobalUndo and useOffline #6815 has
    since landed on this same hook, and the auto-sync effect on 1e14d70 is
    still at 343-351 — the ref work moved wheresyncConfigRef is written, not
    what the auto-sync closure reads. The premise holds: sync still read
    batchSize from the ref and queue from its closure.
  • The consumer count holds; its line number drifted.useOffline still has
    exactly one in-repo consumer destructuring isOnline only, but it is at
    packages/app-shell/src/layout/AppHeader.tsx:132, not :140. Its three
    __tests__ neighbours and sharedInboxFeed.twoSurfaces mock the hook
    outright. Nothing in this repo reaches the queue — confirmed, and it is why
    the pins above had to drive it directly.
  • Published-hook radius accepted. This is a behaviour change reaching an
    unmeasured out-of-repo population, so the changeset is graded minor and
    spells the change out, rather than patch.

No skills/** file is in this diff, so the published-skill line budget does not
apply.

Out of scope, filed unassigned

  • objectui#6856 — the published mobile guide's useOffline snippet destructures
    queue, which OfflineResult does not have, and lists a synced sync state
    that SyncState does not have (while omitting offline). A concrete instance
    of the systemic gap recorded in objectui#5465; not repaired here, since a
    published skill document is outside this card's file surface.
  • objectui#6857 — with the boolean dep, a batchSize smaller than the queue
    still drains one batch and leaves the remainder for the next transition. That
    is the pre-existing behaviour and the fix strictly shrinks the stranded set,
    but whether one auto-sync should chain batches to empty is a decision about
    what batchSize means, not a bug in this guard. The code comment points at
    that card so the edge is not re-derived a third time.

Neither is touched by this PR.


Generated by Claude Code

The auto-sync effect was keyed `[isOnline, enabled]` with exhaustive-deps
suppressed, so its `queue.length === 0` guard was evaluated against the queue
as it stood when `isOnline`/`enabled` last changed. `queueMutation` is not
conditional on being offline, so anything queued while ALREADY online found
the effect asleep and had no auto-sync path at all.
Key the effect on the boolean `queue.length > 0` — the suppression's real
reason (no timer restart per queued mutation) is preserved, because a boolean
does not change when a second mutation lands. Mirror the queue through the
same commit-phase ref the sync config already uses so `sync` stops batching a
stale snapshot against a fresh `batchSize`; that makes `sync` stable and the
dep list genuinely exhaustive, so the `eslint-disable` is deleted rather than
reworded.
Part of #6818
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
The comment and changeset already described the edge the boolean dep leaves
open; point them at the card that owns the decision so the next reader does
not re-derive it.
Part of #6818
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3176.5 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-oWTHtKSW.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.68KB116.36KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)243.30KB61.51KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.43KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.72KB54.58KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:52
@os-sam
os-sam enabled auto-merge August 30, 2026 06:52
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit b87f15bAug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6818-useoffline-stale-queue-guard branch August 30, 2026 07:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(react): useOffline's auto-sync effect guards on a stale queue, so mutations queued while online never auto-sync

2 participants

@os-sam@claude