Skip to content

feat: implement useOffline, usePerformance, usePageTransition hooks for v2.0.7 spec compliance#462

Merged
hotlong merged 4 commits intomainfrom
copilot/implement-new-features-v2-0-7
Feb 12, 2026
Merged

feat: implement useOffline, usePerformance, usePageTransition hooks for v2.0.7 spec compliance#462
hotlong merged 4 commits intomainfrom
copilot/implement-new-features-v2-0-7

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 12, 2026

Completes the "What's New in v2.0.7 (Implementation Required)" roadmap — all 6 remaining ⚠️ Partial spec domains now have runtime implementations.

New hooks in @object-ui/react

  • useOffline — OfflineConfigSchema runtime: online/offline detection via useSyncExternalStore, localStorage-backed mutation queue with FIFO eviction, auto-sync on reconnect, configurable strategy/conflict resolution
  • usePerformance — PerformanceConfigSchema runtime: resolved config (lazy load, virtual scroll, cache strategy, debounce), Web Vitals collection (FCP, LCP), render marking, per-function debounce utility
  • usePageTransition — PageTransitionSchema runtime: Tailwind animate-in/animate-out class generation for 9 transition types (fade, slide, scale, rotate, flip), easing, crossFade, prefers-reduced-motion aware
// Offline mode with sync queue
const { isOnline, queueMutation, sync, showIndicator } = useOffline({
  strategy: 'cache_first',
  sync: { conflictResolution: 'last_write_wins' },
});

// Performance-aware rendering
const { config, metrics, debounce } = usePerformance({
  virtualScroll: { enabled: true, itemHeight: 40 },
  debounceMs: 200,
});

// Page transitions
const { enterClassName, enterStyle, isActive } = usePageTransition({
  type: 'fade', duration: 200, easing: 'ease_out',
});

ROADMAP updates

  • All 12 v2.0.7 domains marked ✅ Complete (was 6/12)
  • Compliance table updated: 90% → 96%
  • Q3 detailed sections updated with checked items

Tests

32 new tests (10 offline, 7 performance, 15 page transition). Total react package: 288 tests across 24 files.


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectui Ready Ready Preview, Comment Feb 12, 2026 3:29am
objectui-demo Ready Ready Preview, Comment Feb 12, 2026 3:29am
objectui-storybook Error Error Feb 12, 2026 3:29am

Request Review

Copilot AI and others added 3 commits February 12, 2026 02:37
…or v2.0.7 spec compliance

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…n, crossFade, auto-sync delay

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement new features in version 2.0.7 feat: implement useOffline, usePerformance, usePageTransition hooks for v2.0.7 spec compliance Feb 12, 2026
Copilot AI requested a review from hotlong February 12, 2026 02:48
@hotlong hotlong marked this pull request as ready for review February 12, 2026 02:51
Copilot AI review requested due to automatic review settings February 12, 2026 02:51
@hotlong hotlong merged commit 11f9daa into main Feb 12, 2026
1 of 4 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements three new React hooks to achieve v2.0.7 spec compliance for ObjectUI. The PR completes the remaining "What's New in v2.0.7" roadmap items by adding runtime implementations for offline/sync, performance monitoring, and page transitions. These hooks follow the established pattern of defining types locally aligned with @objectstack/spec v2.0.7 schemas.

Changes:

  • Implements useOffline hook with online/offline detection via useSyncExternalStore, localStorage-backed mutation queue with FIFO eviction, and auto-sync on reconnect
  • Implements usePerformance hook with Web Vitals collection (FCP, LCP), configurable cache strategy/virtual scroll settings, and debounce utility
  • Implements usePageTransition hook with Tailwind CSS animate-in/out class generation for 9 transition types, easing configuration, and prefers-reduced-motion support
  • Updates ROADMAP.md compliance metrics from 90% to 96%, marking all 12 v2.0.7 domains as complete
  • Adds 32 new tests (10 for useOffline, 7 for usePerformance, 15 for usePageTransition)

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/react/src/hooks/useOffline.ts Implements offline mode detection, sync queue management with localStorage persistence, and auto-sync behavior
packages/react/src/hooks/usePerformance.ts Implements performance config resolution, Web Vitals metrics collection, render tracking, and debounce utility
packages/react/src/hooks/usePageTransition.ts Implements page transition class/style generation with reduced-motion awareness and crossFade support
packages/react/src/hooks/index.ts Exports the three new hooks
packages/react/src/tests/useOffline.test.ts Adds 10 tests covering online/offline detection, mutation queueing, queue size limits, and manual sync
packages/react/src/tests/usePerformance.test.ts Adds 7 tests covering config merging, cache strategies, render counting, and debounce behavior
packages/react/src/tests/usePageTransition.test.ts Adds 15 tests covering all 9 transition types, easing options, duration, and reduced-motion
ROADMAP.md Updates compliance metrics and marks Q3 2026 offline/performance/transition items as complete

Comment on lines +212 to +231

const debounce = useCallback(
<T extends (...args: unknown[]) => void>(fn: T): T => {
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: unknown[]) => {
if (timer) {
clearTimeout(timer);
timersRef.current.delete(timer);
}
timer = setTimeout(() => {
fn(...args);
if (timer) timersRef.current.delete(timer);
}, debounceMs);
timersRef.current.add(timer);
};
return debounced as unknown as T;
},
[debounceMs],
);

Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debounce implementation creates a new debounced function with separate closure state on every call. This means calling perf.debounce(callback) multiple times creates independent debounced functions that don't share the same timer state, defeating the purpose of debouncing.

For example, if a component does:

const search1 = perf.debounce(searchFn);
const search2 = perf.debounce(searchFn);

Then calling search1() and search2() will fire separately after the debounce delay, not as a single debounced call.

The typical pattern for a reusable debounce utility is to either:

  1. Return a stable debounced function from a ref/useMemo (not recreate on each call), OR
  2. Use a Map/WeakMap to cache debounced versions keyed by the original function.

The current implementation suggests it's meant to be called once per callback (like in the example), but the API is misleading since debounce is exposed as a function that can be called multiple times. Consider either documenting this limitation clearly or redesigning to use a memoization strategy.

Suggested change
const debounce = useCallback(
<T extends (...args: unknown[]) => void>(fn: T): T => {
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: unknown[]) => {
if (timer) {
clearTimeout(timer);
timersRef.current.delete(timer);
}
timer = setTimeout(() => {
fn(...args);
if (timer) timersRef.current.delete(timer);
}, debounceMs);
timersRef.current.add(timer);
};
return debounced as unknown as T;
},
[debounceMs],
);
const debounceCacheRef = useRef<WeakMap<Function, Function>>(new WeakMap());
const debounce = useCallback(
<T extends (...args: unknown[]) => void>(fn: T): T => {
const cache = debounceCacheRef.current;
const cached = cache.get(fn) as T | undefined;
if (cached) {
return cached;
}
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = ((...args: unknown[]) => {
if (timer) {
clearTimeout(timer);
timersRef.current.delete(timer);
}
timer = setTimeout(() => {
fn(...args);
if (timer) {
timersRef.current.delete(timer);
}
}, debounceMs);
timersRef.current.add(timer);
}) as unknown as T;
cache.set(fn, debounced as unknown as Function);
return debounced;
},
[debounceMs],
);
// Reset debounced function cache when debounce duration changes
useEffect(() => {
debounceCacheRef.current = new WeakMap();
}, [debounceMs]);

Copilot uses AI. Check for mistakes.
Comment on lines +271 to +273
// Only trigger on isOnline changes, not on every queue change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOnline, enabled]);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-sync effect has an incomplete dependency array. The sync callback is missing from the dependencies but is used inside the effect (line 268). This can lead to stale closures where the effect calls an old version of sync that captures outdated queue state.

The eslint-disable comment indicates this was intentional to avoid triggering on queue changes, but omitting sync creates a bug. The correct approach is to include sync in the dependencies. Since sync is already memoized with useCallback and depends on queue, React will correctly re-run this effect when needed.

Suggested change
// Only trigger on isOnline changes, not on every queue change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOnline, enabled]);
}, [isOnline, enabled, sync]);

Copilot uses AI. Check for mistakes.
Comment on lines +170 to +175
if (crossFade) {
enterStyle.position = 'absolute';
enterStyle.inset = '0';
exitStyle.position = 'absolute';
exitStyle.inset = '0';
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the crossFade feature. The implementation includes logic to set position: absolute and inset: 0 when crossFade is enabled (lines 170-175), but there's no test verifying this behavior. Consider adding a test case to ensure crossFade correctly applies the overlay positioning styles to both enterStyle and exitStyle.

Copilot uses AI. Check for mistakes.
Comment on lines +265 to +273
useEffect(() => {
if (!enabled || !isOnline || queue.length === 0) return;
const timer = setTimeout(() => {
void sync();
}, 100);
return () => clearTimeout(timer);
// Only trigger on isOnline changes, not on every queue change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOnline, enabled]);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the auto-sync feature. The implementation includes an effect that automatically triggers sync when the browser comes back online (lines 265-273), but there's no test verifying this critical behavior. Consider adding a test that:

  1. Queues mutations while offline
  2. Triggers the 'online' event
  3. Verifies that sync is automatically called and the queue is cleared

Copilot uses AI. Check for mistakes.
metrics: PerformanceMetrics;
/** Mark a rendering start (returns stop function). */
markRenderStart: () => () => void;
/** Create a debounced version of a callback. */
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation "Create a debounced version of a callback" is misleading. Due to the implementation creating a new closure each time debounce() is called (as noted in the bug comment for lines 213-230), the debounce function doesn't return a stable debounced version that can be called multiple times. Instead, it creates a new independent debounced wrapper on each call.

The documentation should clarify the intended usage pattern. Based on the example in the hook's JSDoc, it appears the function is meant to be called once to create a debounced callback that's then used multiple times, but this constraint isn't documented in the PerformanceResult interface.

Suggested change
/** Create a debounced version of a callback. */
/**
* Create a debounced version of a callback.
*
* Note: This function is a factory. Each call to `debounce(fn)` creates a new
* debounced wrapper around `fn`. To get a stable debounced callback, call
* `debounce` once (for example in `useMemo` or `useCallback`) and reuse the
* returned function instead of calling `debounce` on every invocation.
*/

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants