ServiceStatusProvider emits 'not wrapped in act(...)' warnings — initial runCheck + setInterval leak past test boundaries #100

Closed
opened 2026-09-18 04:32:22 +00:00 by Mark · 0 comments
Owner

Where: src/components/ServiceStatusProvider.tsx:73-82, src/components/ServiceStatusProvider.test.tsx.

Repro:

$ npx vitest run src/components/ServiceStatusProvider.test.tsx --reporter=verbose
✓ exposes the context to children
✓ transitions to healthy when checkHealth resolves healthy=true
... (9 tests pass)
stderr | exposes the context to children
An update to ServiceStatusProvider inside a test was not wrapped in act(...).

Reproduced on this session, 2026-09-17.

Bug:

ServiceStatusProvider calls runCheck() from a useEffect on mount, then
re-runs every intervalMs (default 5000ms) via setInterval. The check is
async (await checkHealth(...)), then fires setIsChecking(true) →
setStatus → setLastCheckedAt → setIsChecking(false). The simplest test
— exposes the context to children — just renders the provider and reads
context, but the provider's initial useEffect schedules an async state
update that lands after the synchronous render(...) returns and before
the test has a chance to wrap subsequent assertions in act.

The test still passes because React 19 + jsdom tolerates the unwrapped
update, but the warning fires every time and signals that the test isn't
exercising the full effect lifecycle. Worse: setInterval keeps firing
across tests because vitest's default clearMocks: true doesn't reset
React timers; the 5s poll can outlive a test file and bleed into the next.

Fix consistent with existing patterns:

  1. Wrap post-render assertions in act (or waitFor) so the initial
    runCheck resolves before the test exits:

    it("exposes the context to children", async () => {
      let ctxValue: ServiceStatusContextValue | null = null;
      function Reader() {
        ctxValue = useServiceStatus();
        return null;
      }
      render(
        <ServiceStatusProvider>
          <Reader />
        </ServiceStatusProvider>,
      );
      await waitFor(() => expect(ctxValue).not.toBeNull());
      expect(ctxValue!.status).toBe("unknown");
    });
    
  2. For tests that exercise the timer, opt into vi.useFakeTimers() and
    call vi.advanceTimersByTime(intervalMs) inside act to drive each
    poll deterministically. Add an afterEach(() => vi.useRealTimers()) to
    prevent leakage between tests.

  3. The "polling" test already exists (polls at the configured intervalMs)
    but relies on real timers — replace with vi.useFakeTimers() +
    vi.advanceTimersByTime(intervalMs * 2).

Acceptance:

  • npx vitest run src/components/ServiceStatusProvider.test.tsx emits zero
    not wrapped in act(...) warnings.
  • All 9 existing tests still pass.
  • A new test asserts unmounting the provider clears the interval
    deterministically (use vi.useFakeTimers, advance, assert checkHealth
    was called once and not again).
**Where:** `src/components/ServiceStatusProvider.tsx:73-82`, `src/components/ServiceStatusProvider.test.tsx`. **Repro:** ``` $ npx vitest run src/components/ServiceStatusProvider.test.tsx --reporter=verbose ✓ exposes the context to children ✓ transitions to healthy when checkHealth resolves healthy=true ... (9 tests pass) stderr | exposes the context to children An update to ServiceStatusProvider inside a test was not wrapped in act(...). ``` Reproduced on this session, 2026-09-17. **Bug:** `ServiceStatusProvider` calls `runCheck()` from a `useEffect` on mount, then re-runs every `intervalMs` (default 5000ms) via `setInterval`. The check is async (`await checkHealth(...)`), then fires `setIsChecking(true)` → `setStatus` → `setLastCheckedAt` → `setIsChecking(false)`. The simplest test — `exposes the context to children` — just renders the provider and reads context, but the provider's initial `useEffect` schedules an async state update that lands **after** the synchronous `render(...)` returns and before the test has a chance to wrap subsequent assertions in `act`. The test still passes because React 19 + jsdom tolerates the unwrapped update, but the warning fires every time and signals that the test isn't exercising the full effect lifecycle. Worse: `setInterval` keeps firing across tests because vitest's default `clearMocks: true` doesn't reset React timers; the 5s poll can outlive a test file and bleed into the next. **Fix consistent with existing patterns:** 1. Wrap post-render assertions in `act` (or `waitFor`) so the initial `runCheck` resolves before the test exits: ```ts it("exposes the context to children", async () => { let ctxValue: ServiceStatusContextValue | null = null; function Reader() { ctxValue = useServiceStatus(); return null; } render( <ServiceStatusProvider> <Reader /> </ServiceStatusProvider>, ); await waitFor(() => expect(ctxValue).not.toBeNull()); expect(ctxValue!.status).toBe("unknown"); }); ``` 2. For tests that exercise the timer, opt into `vi.useFakeTimers()` and call `vi.advanceTimersByTime(intervalMs)` inside `act` to drive each poll deterministically. Add an `afterEach(() => vi.useRealTimers())` to prevent leakage between tests. 3. The "polling" test already exists (`polls at the configured intervalMs`) but relies on real timers — replace with `vi.useFakeTimers()` + `vi.advanceTimersByTime(intervalMs * 2)`. **Acceptance:** - `npx vitest run src/components/ServiceStatusProvider.test.tsx` emits zero `not wrapped in act(...)` warnings. - All 9 existing tests still pass. - A new test asserts unmounting the provider clears the interval deterministically (use `vi.useFakeTimers`, advance, assert `checkHealth` was called once and not again).
Mark closed this issue 2026-09-18 05:07:25 +00:00
Sign in to join this conversation.
No description provided.