') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); finding(react): useOffline's auto-sync effect guards on a stale queue, so mutations queued while online never auto-sync · Issue #6818 · objectstack-ai/objectui · GitHub
Skip to content

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

Description

@claude

Noticed while implementing objectui#6797 (the react-hooks/refs write on this
same hook). Not repaired there: objectui#6797 moved when the sync-config ref is
written and deliberately preserved every observable timing, so changing what the
auto-sync closure reads would have been a behaviour change smuggled in under a
lint fix.

What

packages/react/src/hooks/useOffline.ts:343-351 (measured on a77a00c2c):

useEffect(()=>{if(!enabled||!isOnline||queue.length===0)return;consttimer=setTimeout(()=>{voidsync();},100);return()=>clearTimeout(timer);// Only trigger on isOnline changes, not on every queue change// eslint-disable-next-line react-hooks/exhaustive-deps},[isOnline,enabled]);

Two consequences of the deliberately narrow dep list, neither of them asserted
by any test today:

  1. The guard is evaluated against a stale queue. If the queue is empty at
    the moment isOnline or enabled last changed, the effect returns early and
    is never re-run by a later queueMutation. Mutations queued while already
    online therefore have no auto-sync path — only an explicit sync() call
    drains them.
  2. The retained sync closure reads a fresh batchSize but a stale queue.
    sync reads syncConfigRef.current?.batchSize through a ref (so: newest)
    while queue comes from its own closure (so: the snapshot from the render
    where isOnline last changed). The two halves of the same call disagree about
    how current they are.

Why it may matter, and why it may not

The eslint-disable comment says the narrow deps are intentional — re-running on
every queue change would restart the 100ms timer on each queued mutation. That
reasoning is sound for point 2's timer behaviour; what is not established is
that point 1's early return was intended. A "sync when you come back online"
feature that silently does nothing for anything queued while online reads more
like a gap than a decision.

No user-visible break is measured and none is claimed.useOffline has one
in-repo consumer (packages/app-shell/src/layout/AppHeader.tsx:140) and it
destructures isOnline only, so nothing in this repo reaches the sync queue at
all. Filed as an observation for triage, not asserting impact.

Suggested shape if triage takes it

Key the effect on a scalar derived from the queue rather than the queue object
queue.length > 0 — so the guard re-evaluates when the queue becomes non-empty
without restarting the timer on every individual mutation. That is a behaviour
change and wants its own card, which is this one.

Dedupe

/search/issues answers 403 for this seat, so this went through the REST list
endpoint plus a local grep: 254 open issues collected, zero hits for useOffline,
auto-sync, stale queue or exhaustive-deps other than objectui#6797 itself.
Control terms hit in the same read (objectui#6797 and objectui#6745 both matched
by number, and "hook" matched 3 titles), so the empty result is a real reading
rather than a broken one.


Generated by Claude Code

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions