Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/7257-metadata-hmr-give-up-after-404.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/console': patch
---

`MetadataHmrReloader` stops flooding a production-posture deployment with
`GET /api/v1/dev/metadata-events` 404s (objectui#7257).

The dev-only HMR component subscribes via `EventSource`, gated on
`import.meta.env.DEV`. That gate is not airtight against every rig: a
"prod-like" build/serve setup that forces `NODE_ENV=development` for the
*build tooling* while running the server itself in production posture can
bake `DEV === true` into the shipped bundle even though the server never
mounts the dev route there — and the old reconnect loop treated every closed
connection as transient, retrying on a fixed `reconnectDelayMs` (2s)
forever. On an env host that is ~30 requests/minute of 404s per open
record/list page, drowning out the legitimate `sys_inbox_message` /
`sys_notification_receipt` polling in the same console.

The first `connect()` attempt now doubles as the real capability probe: if
the stream closes before it ever reaches `open`, the component gives up for
good instead of retrying (and specifically not a longer interval either —
that would still spam 404s, just slower). A stream that DID open at least
once and later drops — a real dev-server restart or network blip — keeps
reconnecting exactly as before.

No production-side replacement is introduced here: this component's only
job is turning "a metadata file changed on disk" into a full reload, and
production deployments have no such file-system event to watch. The
separate Studio-left-nav-doesn't-refresh caching issue does not go through
this SSE stream and needs its own fix.
169 changes: 169 additions & 0 deletions apps/console/src/components/MetadataHmrReloader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7257 — a production-posture console kept polling
* `GET /api/v1/dev/metadata-events` every `reconnectDelayMs` forever, each
* attempt landing on a 404 because the server doesn't mount this dev-only
* route there (~30 requests/min, flooding the console and drowning out the
* legitimate `sys_inbox_message` / `sys_notification_receipt` polling).
*
* `enabled` (default `import.meta.env.DEV`) is the primary gate, but it is
* not airtight: a build can end up with `DEV === true` baked in even while
* the server it talks to is in production posture (a "prod-like" rig that
* forces `NODE_ENV=development` for the *build tooling* is one concrete way
* this happens — see the file header of `MetadataHmrReloader.tsx`). So the
* fix under test lives one layer down, in the reconnect loop itself: the
* first `connect()` attempt doubles as a capability probe. A stream that
* closes before ever reaching `open` is read as "the server doesn't support
* this", and the component gives up for good — no retry, and specifically
* NOT a longer retry interval (that would still spam 404s, just slower). A
* stream that DID open at least once and later drops is a different case —
* that proves the route exists, so ordinary reconnect-on-drop keeps working
* for real dev-server restarts / network blips.
*
* A fake `EventSource` drives both branches directly (this repo's jsdom-ish
* `happy-dom` test environment does not implement `EventSource`), so each
* case controls exactly which lifecycle events fire and in what order.
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { MetadataHmrReloader } from './MetadataHmrReloader';

type Listener = (event: unknown) => void;

/** Minimal, test-controlled stand-in for the browser `EventSource`. */
class FakeEventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
/** Every instance constructed across the whole test file, in order. */
static instances: FakeEventSource[] = [];

readyState = FakeEventSource.CONNECTING;
private readonly listeners = new Map<string, Listener[]>();

constructor(public readonly url: string) {
FakeEventSource.instances.push(this);
}

addEventListener(type: string, listener: Listener) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
}

removeEventListener() {
/* not exercised — the component never calls this */
}

close() {
this.readyState = FakeEventSource.CLOSED;
}

private emit(type: string, event: unknown = {}) {
for (const listener of this.listeners.get(type) ?? []) listener(event);
}

/** The 404 case: the connection never reaches `open` before it closes. */
failWithoutOpening() {
this.readyState = FakeEventSource.CLOSED;
this.emit('error');
}

/** The dev-server-restart case: a real connection that later drops. */
openThenDrop() {
this.readyState = FakeEventSource.OPEN;
this.emit('open');
this.readyState = FakeEventSource.CLOSED;
this.emit('error');
}
}

describe('MetadataHmrReloader — give up after the first connection fails without opening', () => {
const originalEventSource = (globalThis as { EventSource?: unknown }).EventSource;

beforeEach(() => {
FakeEventSource.instances = [];
(globalThis as { EventSource?: unknown }).EventSource = FakeEventSource;
});

afterEach(() => {
cleanup();
(globalThis as { EventSource?: unknown }).EventSource = originalEventSource;
vi.useRealTimers();
});

it('THE FIX: a 404-shaped failure (closed before `open`) is not retried at all', () => {
vi.useFakeTimers();

render(
<MetadataHmrReloader
enabled
url="/api/v1/dev/metadata-events"
reconnectDelayMs={2000}
/>,
);

expect(FakeEventSource.instances).toHaveLength(1);
FakeEventSource.instances[0].failWithoutOpening();

// Advance well past what would have been ~15 reconnect attempts under
// the old fixed-interval retry — the regression this pins is "polls
// forever", not "polls a little less".
vi.advanceTimersByTime(2000 * 15);

expect(FakeEventSource.instances).toHaveLength(1);
});

it('⛔ does not fall back to a longer interval either — zero further attempts, not a slower loop', () => {
vi.useFakeTimers();

render(
<MetadataHmrReloader
enabled
url="/api/v1/dev/metadata-events"
reconnectDelayMs={2000}
/>,
);

FakeEventSource.instances[0].failWithoutOpening();

// Even an interval an order of magnitude longer than the configured one
// must not produce a second attempt.
vi.advanceTimersByTime(2000 * 100);

expect(FakeEventSource.instances).toHaveLength(1);
});

it('CONTROL: a stream that opened once and then drops keeps reconnecting (real dev-server churn)', () => {
vi.useFakeTimers();

render(
<MetadataHmrReloader
enabled
url="/api/v1/dev/metadata-events"
reconnectDelayMs={2000}
/>,
);

expect(FakeEventSource.instances).toHaveLength(1);
FakeEventSource.instances[0].openThenDrop();

vi.advanceTimersByTime(2000);
expect(FakeEventSource.instances).toHaveLength(2);

// The reconnect is itself a fresh probe: if it also opens and later
// drops, the loop keeps going — this is the behavior the fix must NOT
// disturb for a route that genuinely exists.
FakeEventSource.instances[1].openThenDrop();
vi.advanceTimersByTime(2000);
expect(FakeEventSource.instances).toHaveLength(3);
});

it('does not subscribe at all when disabled (unaffected by this change)', () => {
render(<MetadataHmrReloader enabled={false} url="/api/v1/dev/metadata-events" />);

expect(FakeEventSource.instances).toHaveLength(0);
});
});
42 changes: 42 additions & 0 deletions apps/console/src/components/MetadataHmrReloader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@
* - `enabled` defaults to `import.meta.env.DEV` so production builds
* never run this component.
* - SSR-safe: no-op when `window`/`EventSource` are unavailable.
*
* Give-up-after-first-failure (objectui#7257)
* `enabled` alone is not a hard guarantee that the server actually mounts
* `/api/v1/dev/metadata-events` — a build can end up with
* `import.meta.env.DEV === true` baked in (e.g. a "prod-like" rig that
* forces `NODE_ENV=development` for the *build tooling* while running the
* server itself in production posture) even though the route is absent
* there. Treat the very first `connect()` attempt as the real capability
* probe: if the stream is closed before it ever reaches `open`, the server
* doesn't support it (a 404, most likely), and retrying on a fixed
* interval would just recreate that 404 every `reconnectDelayMs` forever —
* so this gives up permanently instead of retrying or backing off. A
* stream that HAS opened at least once and later drops (dev server
* restart, network blip) keeps reconnecting as before — that scenario
* means the route genuinely exists, so it is ordinary dev-time churn, not
* a production posture mismatch.
*
* No production-posture substitute
* This component's only job is dev-time "a metadata file changed on disk"
* -> full reload. Production deployments never have metadata files
* changing on the server's disk after a publish, so there is nothing for
* an equivalent production channel to watch — this card intentionally
* ships no replacement. A separate, unrelated caching bug (the Studio left
* nav not refreshing after a metadata change) needs its own fix elsewhere;
* it does not go through this SSE stream today either way.
*/

import { useEffect, useRef } from 'react';
Expand Down Expand Up @@ -50,6 +75,11 @@ export function MetadataHmrReloader({
let es: EventSource | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
// Set once the stream has ever reached `open`. Until then, a closed
// connection is read as "this server doesn't mount the route" rather
// than "a transient drop" — see the give-up-after-first-failure note
// in the file header (objectui#7257).
let hasOpenedOnce = false;

const scheduleReload = (reason: string) => {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
Expand Down Expand Up @@ -92,16 +122,28 @@ export function MetadataHmrReloader({
if (cancelled) return;
try {
es = new EventSource(url);
es.addEventListener('open', () => {
hasOpenedOnce = true;
});
es.addEventListener('metadata-change', onChange as EventListener);
es.addEventListener('reload', onReload as EventListener);
es.addEventListener('error', () => {
if (cancelled) return;
if (es?.readyState === EventSource.CLOSED) {
es = null;
if (!hasOpenedOnce) {
// Never got past the first attempt — most likely a 404
// (production posture doesn't mount this dev-only route).
// Retrying on a timer would just recreate the same 404 every
// `reconnectDelayMs` forever; give up instead of extending
// the interval.
return;
}
retryTimer = setTimeout(connect, reconnectDelayMs);
}
});
} catch {
if (!hasOpenedOnce) return;
retryTimer = setTimeout(connect, reconnectDelayMs);
}
};
Expand Down
Loading