@agent-native/core 0.161.9 → 0.161.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/calendar/changelog/2026-08-18-calendar-event-time-editing-respects-the-event-timezone.md +6 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +1 -1
- package/corpus/templates/clips/app/i18n/en-US.ts +1 -1
- package/corpus/templates/clips/changelog/2026-08-18-centered-clip-play-button-and-comment-panel-hint.md +6 -0
- package/corpus/templates/content/actions/export-document.ts +68 -3
- package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
- package/corpus/templates/content/shared/document-export.ts +25 -0
- package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
- package/corpus/templates/design/app/pages/Index.tsx +45 -1
- package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
- package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
- package/corpus/templates/factory/actions/start-builder-for-item.ts +241 -22
- package/corpus/templates/factory/server/plugins/agent-chat.ts +9 -5
- package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +50 -14
- package/corpus/templates/factory/server/triage/builder-executor.ts +1 -0
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +231 -9
- package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
- package/corpus/templates/slides/app/context/DeckContext.tsx +99 -3
- package/corpus/templates/slides/app/i18n/en-US.ts +6 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +8 -2
- package/dist/action.js +19 -2
- package/dist/agent/chat-health-alert.d.ts +25 -0
- package/dist/agent/chat-health-alert.js +141 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
- package/dist/client/embed-auth.js +40 -3
- package/dist/client/route-state.js +42 -5
- package/dist/client/use-change-version.js +71 -25
- package/dist/client/use-db-sync.d.ts +1 -0
- package/dist/client/use-db-sync.js +223 -45
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.js +37 -4
- package/dist/collab/client.js +17 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-chat-plugin.js +15 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/poll.d.ts +22 -14
- package/dist/server/poll.js +243 -67
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every outcome is distinguishable. "Not enough turns to judge" and "healthy"
|
|
3
|
+
* are different answers, and a check that could not run is neither — collapsing
|
|
4
|
+
* them is how a monitor reports all-clear through an outage.
|
|
5
|
+
*/
|
|
6
|
+
export type ChatHealthAlertOutcome = {
|
|
7
|
+
status: "healthy";
|
|
8
|
+
turns: number;
|
|
9
|
+
badRate: number;
|
|
10
|
+
} | {
|
|
11
|
+
status: "insufficient-data";
|
|
12
|
+
turns: number;
|
|
13
|
+
} | {
|
|
14
|
+
status: "cooldown";
|
|
15
|
+
retryAfterMs: number;
|
|
16
|
+
} | {
|
|
17
|
+
status: "alerted";
|
|
18
|
+
turns: number;
|
|
19
|
+
badRate: number;
|
|
20
|
+
recipients: number;
|
|
21
|
+
} | {
|
|
22
|
+
status: "check-failed";
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function checkChatHealthAndAlert(now?: number): Promise<ChatHealthAlertOutcome>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { getDbExec } from "../db/client.js";
|
|
2
|
+
import { notify } from "../notifications/registry.js";
|
|
3
|
+
import { getSetting, putSetting } from "../settings/store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Pages the people who can act when an app's chat stops answering.
|
|
6
|
+
*
|
|
7
|
+
* The detector for this already existed as `scripts/chat-health.mjs --strict`,
|
|
8
|
+
* correctly calibrated and exiting 1 on a partial outage — but nothing ever ran
|
|
9
|
+
* it and nothing ever paged, so an app answering 11% of its turns was found by
|
|
10
|
+
* a user posting in Slack. This is the missing half: the same measurement, on
|
|
11
|
+
* the durable sweep that already drives stale reaping, scoped to the one app it
|
|
12
|
+
* runs in so no cross-app credential has to exist anywhere.
|
|
13
|
+
*/
|
|
14
|
+
/** Turns are scored over this window on every sweep. */
|
|
15
|
+
const WINDOW_MS = 60 * 60_000;
|
|
16
|
+
/**
|
|
17
|
+
* Below this, a rate is noise: one failed turn out of two is 50% and means
|
|
18
|
+
* nothing. Chat-health's own fleet view showed apps sitting at 100% on a single
|
|
19
|
+
* turn all day.
|
|
20
|
+
*/
|
|
21
|
+
const MIN_TURNS = 5;
|
|
22
|
+
/**
|
|
23
|
+
* Deliberately far above `chat-health`'s 0.1 review budget. That threshold
|
|
24
|
+
* answers "is this app degraded", which is a question for a dashboard. This one
|
|
25
|
+
* answers "is chat down", which is the only question worth waking someone for.
|
|
26
|
+
*/
|
|
27
|
+
const BAD_RATE_THRESHOLD = 0.5;
|
|
28
|
+
/** One page per outage, not one per sweep. */
|
|
29
|
+
const COOLDOWN_MS = 60 * 60_000;
|
|
30
|
+
const LAST_ALERT_SETTING_KEY = "chat-health-alert:last-paged-at";
|
|
31
|
+
/**
|
|
32
|
+
* Scores the LAST run of each turn in the window, matching how
|
|
33
|
+
* `scripts/chat-health.mjs` reports so a page and the CLI never disagree.
|
|
34
|
+
* User-stopped turns are excluded: someone hitting Stop is not an outage.
|
|
35
|
+
*/
|
|
36
|
+
async function countRecentTurns(since) {
|
|
37
|
+
const { rows } = await getDbExec().execute({
|
|
38
|
+
sql: `WITH ranked AS (
|
|
39
|
+
SELECT turn_id, status,
|
|
40
|
+
ROW_NUMBER() OVER (
|
|
41
|
+
PARTITION BY turn_id ORDER BY started_at DESC
|
|
42
|
+
) AS rn
|
|
43
|
+
FROM agent_runs
|
|
44
|
+
WHERE started_at >= ?
|
|
45
|
+
AND turn_id IS NOT NULL
|
|
46
|
+
AND id NOT LIKE 'job-%'
|
|
47
|
+
)
|
|
48
|
+
SELECT COUNT(*) AS turns,
|
|
49
|
+
SUM(CASE WHEN status = 'errored' THEN 1 ELSE 0 END) AS bad
|
|
50
|
+
FROM ranked
|
|
51
|
+
WHERE rn = 1 AND status <> 'aborted'`,
|
|
52
|
+
args: [since],
|
|
53
|
+
});
|
|
54
|
+
const row = rows[0];
|
|
55
|
+
return {
|
|
56
|
+
turns: Number(row?.turns ?? 0),
|
|
57
|
+
bad: Number(row?.bad ?? 0),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Owners and admins — the people who can actually change a model or a key. */
|
|
61
|
+
async function alertRecipients() {
|
|
62
|
+
const { rows } = await getDbExec().execute({
|
|
63
|
+
sql: `SELECT DISTINCT email FROM org_members
|
|
64
|
+
WHERE role IN ('owner', 'admin')`,
|
|
65
|
+
args: [],
|
|
66
|
+
});
|
|
67
|
+
return rows
|
|
68
|
+
.map((r) => String(r.email ?? ""))
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
export async function checkChatHealthAndAlert(now = Date.now()) {
|
|
72
|
+
let counts;
|
|
73
|
+
try {
|
|
74
|
+
counts = await countRecentTurns(now - WINDOW_MS);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// A check that could not read the ledger has not found the app healthy.
|
|
78
|
+
return { status: "check-failed", reason: String(error) };
|
|
79
|
+
}
|
|
80
|
+
if (counts.turns < MIN_TURNS) {
|
|
81
|
+
return { status: "insufficient-data", turns: counts.turns };
|
|
82
|
+
}
|
|
83
|
+
const badRate = counts.bad / counts.turns;
|
|
84
|
+
if (badRate < BAD_RATE_THRESHOLD) {
|
|
85
|
+
return { status: "healthy", turns: counts.turns, badRate };
|
|
86
|
+
}
|
|
87
|
+
// A cooldown stamp we could not READ is not a cooldown that is absent:
|
|
88
|
+
// treating it as absent pages on every sweep for as long as settings stay
|
|
89
|
+
// unreadable, which is exactly the spam the cooldown exists to prevent.
|
|
90
|
+
let stored;
|
|
91
|
+
try {
|
|
92
|
+
stored = (await getSetting(LAST_ALERT_SETTING_KEY));
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { status: "check-failed", reason: String(error) };
|
|
96
|
+
}
|
|
97
|
+
const lastPagedAt = Number(stored?.at ?? 0);
|
|
98
|
+
if (Number.isFinite(lastPagedAt) && now - lastPagedAt < COOLDOWN_MS) {
|
|
99
|
+
return {
|
|
100
|
+
status: "cooldown",
|
|
101
|
+
retryAfterMs: COOLDOWN_MS - (now - lastPagedAt),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
let recipients;
|
|
105
|
+
try {
|
|
106
|
+
recipients = await alertRecipients();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { status: "check-failed", reason: String(error) };
|
|
110
|
+
}
|
|
111
|
+
const pct = Math.round(badRate * 100);
|
|
112
|
+
for (const owner of recipients) {
|
|
113
|
+
await notify({
|
|
114
|
+
severity: "critical",
|
|
115
|
+
title: `Chat is failing: ${pct}% of turns ended without an answer`,
|
|
116
|
+
body: `${counts.bad} of ${counts.turns} turns in the last hour ended without ` +
|
|
117
|
+
`an answer. Run \`node scripts/chat-health.mjs --hours 1\` for the ` +
|
|
118
|
+
`per-reason breakdown.`,
|
|
119
|
+
metadata: {
|
|
120
|
+
turns: counts.turns,
|
|
121
|
+
bad: counts.bad,
|
|
122
|
+
badRate,
|
|
123
|
+
windowMs: WINDOW_MS,
|
|
124
|
+
},
|
|
125
|
+
}, { owner }).catch((error) => {
|
|
126
|
+
// One undeliverable recipient must not suppress the rest.
|
|
127
|
+
console.error("[chat-health-alert] notify failed for a recipient:", error);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Stamped only after an attempt was actually made, so a failed page retries
|
|
131
|
+
// on the next sweep instead of being silenced by its own cooldown.
|
|
132
|
+
await putSetting(LAST_ALERT_SETTING_KEY, { at: now }).catch((error) => {
|
|
133
|
+
console.error("[chat-health-alert] could not stamp cooldown:", error);
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
status: "alerted",
|
|
137
|
+
turns: counts.turns,
|
|
138
|
+
badRate,
|
|
139
|
+
recipients: recipients.length,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -50,7 +50,27 @@ export function useNearBottomAutoscroll({ followKey, streaming = false, threshol
|
|
|
50
50
|
const followGenerationRef = useRef(0);
|
|
51
51
|
const lastScrollTopRef = useRef(0);
|
|
52
52
|
const lastTouchYRef = useRef(null);
|
|
53
|
+
const pendingAnimationFrameIdsRef = useRef(new Set());
|
|
54
|
+
const pendingTimeoutIdsRef = useRef(new Set());
|
|
53
55
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
|
56
|
+
const cancelPendingScrolls = useCallback(() => {
|
|
57
|
+
for (const id of pendingAnimationFrameIdsRef.current) {
|
|
58
|
+
window.cancelAnimationFrame(id);
|
|
59
|
+
}
|
|
60
|
+
pendingAnimationFrameIdsRef.current.clear();
|
|
61
|
+
for (const id of pendingTimeoutIdsRef.current) {
|
|
62
|
+
window.clearTimeout(id);
|
|
63
|
+
}
|
|
64
|
+
pendingTimeoutIdsRef.current.clear();
|
|
65
|
+
}, []);
|
|
66
|
+
const scheduleAnimationFrame = useCallback((callback) => {
|
|
67
|
+
let id = 0;
|
|
68
|
+
id = window.requestAnimationFrame((time) => {
|
|
69
|
+
pendingAnimationFrameIdsRef.current.delete(id);
|
|
70
|
+
callback(time);
|
|
71
|
+
});
|
|
72
|
+
pendingAnimationFrameIdsRef.current.add(id);
|
|
73
|
+
}, []);
|
|
54
74
|
const isAtBottom = useCallback((el) => el.scrollHeight - el.scrollTop - el.clientHeight <= threshold, [threshold]);
|
|
55
75
|
const setFollowingBottom = useCallback((following, forceGeneration = false, el) => {
|
|
56
76
|
if (forceGeneration || isNearBottomRef.current !== following) {
|
|
@@ -213,14 +233,24 @@ export function useNearBottomAutoscroll({ followKey, streaming = false, threshol
|
|
|
213
233
|
};
|
|
214
234
|
}, [detachFromBottom, enabled, scrollToBottomIfFollowing, updateBottomState]);
|
|
215
235
|
const scrollToBottomAfterPaint = useCallback(() => {
|
|
236
|
+
cancelPendingScrolls();
|
|
216
237
|
const generation = followGenerationRef.current;
|
|
217
238
|
scrollToBottomIfFollowing(generation);
|
|
218
|
-
|
|
239
|
+
scheduleAnimationFrame(() => {
|
|
219
240
|
scrollToBottomIfFollowing(generation);
|
|
220
|
-
|
|
241
|
+
scheduleAnimationFrame(() => scrollToBottomIfFollowing(generation));
|
|
221
242
|
});
|
|
222
|
-
window.setTimeout(() =>
|
|
223
|
-
|
|
243
|
+
const timeoutId = window.setTimeout(() => {
|
|
244
|
+
pendingTimeoutIdsRef.current.delete(timeoutId);
|
|
245
|
+
scrollToBottomIfFollowing(generation);
|
|
246
|
+
}, 80);
|
|
247
|
+
pendingTimeoutIdsRef.current.add(timeoutId);
|
|
248
|
+
}, [cancelPendingScrolls, scheduleAnimationFrame, scrollToBottomIfFollowing]);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!enabled)
|
|
251
|
+
cancelPendingScrolls();
|
|
252
|
+
return cancelPendingScrolls;
|
|
253
|
+
}, [cancelPendingScrolls, enabled]);
|
|
224
254
|
const resumeFollowing = useCallback(() => {
|
|
225
255
|
const el = scrollRef.current;
|
|
226
256
|
setFollowingBottom(true, true, el ?? undefined);
|
|
@@ -11,6 +11,7 @@ const GUARDED_METHODS = new Set(["GET", "HEAD"]);
|
|
|
11
11
|
const AUTH_FAILURE_HEADER = "x-agent-native-auth-circuit-breaker";
|
|
12
12
|
const MCP_CHAT_BRIDGE_VIEWPORT_STYLE_ID = "agent-native-mcp-chat-bridge-viewport";
|
|
13
13
|
const MCP_CHAT_BRIDGE_VIEWPORT_HEIGHT = 560;
|
|
14
|
+
let pendingMcpChatBridgeViewportNotification = null;
|
|
14
15
|
const authFailureCache = new Map();
|
|
15
16
|
let embedAuthFailure = null;
|
|
16
17
|
function browserWindow() {
|
|
@@ -211,11 +212,38 @@ function notifyMcpChatBridgeViewportHeight(win) {
|
|
|
211
212
|
// Cross-host embeds can deny parent messaging in tests or strict sandboxes.
|
|
212
213
|
}
|
|
213
214
|
};
|
|
215
|
+
const pending = pendingMcpChatBridgeViewportNotification;
|
|
216
|
+
pendingMcpChatBridgeViewportNotification = null;
|
|
217
|
+
if (pending) {
|
|
218
|
+
if (pending.animationFrameId !== null) {
|
|
219
|
+
pending.win.cancelAnimationFrame?.(pending.animationFrameId);
|
|
220
|
+
}
|
|
221
|
+
for (const id of pending.timeoutIds)
|
|
222
|
+
pending.win.clearTimeout(id);
|
|
223
|
+
}
|
|
214
224
|
notify();
|
|
225
|
+
const nextPending = {
|
|
226
|
+
win,
|
|
227
|
+
animationFrameId: null,
|
|
228
|
+
timeoutIds: [],
|
|
229
|
+
};
|
|
230
|
+
pendingMcpChatBridgeViewportNotification = nextPending;
|
|
231
|
+
const notifyIfCurrent = () => {
|
|
232
|
+
// Some hosts expose requestAnimationFrame/timers from a different clock
|
|
233
|
+
// than the one used by clearTimeout. The identity guard keeps a superseded
|
|
234
|
+
// setup from notifying even when cancellation cannot reach that clock.
|
|
235
|
+
if (pendingMcpChatBridgeViewportNotification !== nextPending)
|
|
236
|
+
return;
|
|
237
|
+
notify();
|
|
238
|
+
};
|
|
215
239
|
try {
|
|
216
|
-
win.requestAnimationFrame
|
|
217
|
-
|
|
218
|
-
|
|
240
|
+
if (win.requestAnimationFrame) {
|
|
241
|
+
nextPending.animationFrameId = win.requestAnimationFrame(notifyIfCurrent);
|
|
242
|
+
}
|
|
243
|
+
if (win.setTimeout) {
|
|
244
|
+
nextPending.timeoutIds.push(win.setTimeout(notifyIfCurrent, 250));
|
|
245
|
+
nextPending.timeoutIds.push(win.setTimeout(notifyIfCurrent, 1000));
|
|
246
|
+
}
|
|
219
247
|
}
|
|
220
248
|
catch {
|
|
221
249
|
// Timers are a progressive enhancement for late host bridge initialization.
|
|
@@ -223,6 +251,15 @@ function notifyMcpChatBridgeViewportHeight(win) {
|
|
|
223
251
|
}
|
|
224
252
|
/** Internal test helper. Do not use in app code. */
|
|
225
253
|
export function _resetEmbedAuthForTests() {
|
|
254
|
+
if (pendingMcpChatBridgeViewportNotification) {
|
|
255
|
+
const pending = pendingMcpChatBridgeViewportNotification;
|
|
256
|
+
pendingMcpChatBridgeViewportNotification = null;
|
|
257
|
+
if (pending.animationFrameId !== null) {
|
|
258
|
+
pending.win.cancelAnimationFrame?.(pending.animationFrameId);
|
|
259
|
+
}
|
|
260
|
+
for (const id of pending.timeoutIds)
|
|
261
|
+
pending.win.clearTimeout(id);
|
|
262
|
+
}
|
|
226
263
|
installed = false;
|
|
227
264
|
memoryToken = null;
|
|
228
265
|
mcpChatBridgeActive = false;
|
|
@@ -37,12 +37,39 @@ function defaultCommandDedupKey(command) {
|
|
|
37
37
|
function currentRouterPath(location) {
|
|
38
38
|
return `${location.pathname}${location.search}${location.hash}`;
|
|
39
39
|
}
|
|
40
|
+
function shallowEqualNavigationState(left, right) {
|
|
41
|
+
if (Object.is(left, right))
|
|
42
|
+
return true;
|
|
43
|
+
if (!left ||
|
|
44
|
+
!right ||
|
|
45
|
+
typeof left !== "object" ||
|
|
46
|
+
typeof right !== "object") {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const leftKeys = Object.keys(left);
|
|
50
|
+
const rightKeys = Object.keys(right);
|
|
51
|
+
if (leftKeys.length !== rightKeys.length)
|
|
52
|
+
return false;
|
|
53
|
+
for (const key of leftKeys) {
|
|
54
|
+
if (!Object.prototype.hasOwnProperty.call(right, key) ||
|
|
55
|
+
!Object.is(left[key], right[key])) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
40
61
|
function stringifyForWriteDedup(value) {
|
|
41
62
|
try {
|
|
42
|
-
|
|
63
|
+
const serialized = JSON.stringify(value);
|
|
64
|
+
return typeof serialized === "string"
|
|
65
|
+
? serialized
|
|
66
|
+
: Symbol("unserializable navigation state");
|
|
43
67
|
}
|
|
44
68
|
catch {
|
|
45
|
-
|
|
69
|
+
// Do not turn a serialization failure into a reusable string key. A
|
|
70
|
+
// distinct token lets a changed unserializable state reach the write path
|
|
71
|
+
// and surface its real error instead of being silently deduplicated.
|
|
72
|
+
return Symbol("unserializable navigation state");
|
|
46
73
|
}
|
|
47
74
|
}
|
|
48
75
|
function resolveAgentChatViewTransitionOption(option, command, path) {
|
|
@@ -65,10 +92,10 @@ export function useSemanticNavigationState(options) {
|
|
|
65
92
|
const commandKeys = useMemo(() => uniqueKeys(options.commandKeys ?? ["navigate"]), [options.commandKeys]);
|
|
66
93
|
const commandQueryKey = useMemo(() => options.commandQueryKey ?? ["navigate-command"], [options.commandQueryKey]);
|
|
67
94
|
const navigationState = options.state ?? null;
|
|
68
|
-
const navigationWriteDedup = stringifyForWriteDedup({
|
|
95
|
+
const navigationWriteDedup = useMemo(() => stringifyForWriteDedup({
|
|
69
96
|
keys: navigationKeys,
|
|
70
97
|
state: navigationState,
|
|
71
|
-
});
|
|
98
|
+
}), [navigationKeys, navigationState]);
|
|
72
99
|
const getCommandDedupKeyRef = useRef(options.getCommandDedupKey);
|
|
73
100
|
const onCommandRef = useRef(options.onCommand);
|
|
74
101
|
const onErrorRef = useRef(options.onError);
|
|
@@ -184,7 +211,17 @@ export function useAgentRouteState(options) {
|
|
|
184
211
|
browserTabId ?? "global",
|
|
185
212
|
], [browserTabId, commandKey, options.commandQueryKey]);
|
|
186
213
|
const routeLocation = useMemo(() => routeLocationFromReactRouter(location), [location]);
|
|
187
|
-
|
|
214
|
+
// Callers may include app-local state in this callback in addition to the
|
|
215
|
+
// router location. Derive on every render so those values are not frozen at
|
|
216
|
+
// the last route change, then retain the previous object when its shallow
|
|
217
|
+
// values are unchanged. This keeps the write-dedup serialization off the
|
|
218
|
+
// unrelated-render path without hiding captured state updates.
|
|
219
|
+
const derivedNavigationState = options.getNavigationState(routeLocation) ?? null;
|
|
220
|
+
const navigationStateRef = useRef(derivedNavigationState);
|
|
221
|
+
if (!shallowEqualNavigationState(navigationStateRef.current, derivedNavigationState)) {
|
|
222
|
+
navigationStateRef.current = derivedNavigationState;
|
|
223
|
+
}
|
|
224
|
+
const navigationState = navigationStateRef.current;
|
|
188
225
|
return useSemanticNavigationState({
|
|
189
226
|
state: navigationState,
|
|
190
227
|
navigationKeys,
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
* refetch when that source fires. A poll heartbeat with no event does
|
|
32
32
|
* nothing.
|
|
33
33
|
*/
|
|
34
|
-
import { useSyncExternalStore } from "react";
|
|
34
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
35
|
+
const MAX_TRACKED_SOURCES = 1_000;
|
|
36
|
+
const ZERO_SNAPSHOT = () => 0;
|
|
35
37
|
class ChangeVersionStore {
|
|
36
38
|
versions = new Map();
|
|
37
|
-
listeners = new
|
|
38
|
-
|
|
39
|
+
listeners = new Map();
|
|
40
|
+
activeSources = new Map();
|
|
39
41
|
/**
|
|
40
42
|
* Advance the counter for `source` to at least `version`. Returns true if
|
|
41
43
|
* the counter actually moved (so callers can avoid spurious work).
|
|
@@ -46,31 +48,60 @@ class ChangeVersionStore {
|
|
|
46
48
|
const current = this.versions.get(source) ?? 0;
|
|
47
49
|
if (version > current) {
|
|
48
50
|
this.versions.set(source, version);
|
|
49
|
-
this.
|
|
50
|
-
this.
|
|
51
|
+
this.evictUnobservedSources();
|
|
52
|
+
for (const listener of this.listeners.get(source) ?? [])
|
|
53
|
+
listener();
|
|
51
54
|
return true;
|
|
52
55
|
}
|
|
53
56
|
return false;
|
|
54
57
|
}
|
|
55
58
|
get(source) {
|
|
56
|
-
|
|
57
|
-
// values, so we serve from a cached map that we only update inside
|
|
58
|
-
// `bump`.
|
|
59
|
-
const cached = this.cachedSnapshots.get(source);
|
|
60
|
-
if (cached !== undefined)
|
|
61
|
-
return cached;
|
|
62
|
-
this.cachedSnapshots.set(source, 0);
|
|
63
|
-
return 0;
|
|
59
|
+
return this.versions.get(source) ?? 0;
|
|
64
60
|
}
|
|
65
|
-
subscribe(listener) {
|
|
66
|
-
|
|
61
|
+
subscribe(sources, listener) {
|
|
62
|
+
const uniqueSources = new Set(sources.filter(Boolean));
|
|
63
|
+
for (const source of uniqueSources) {
|
|
64
|
+
this.activeSources.set(source, (this.activeSources.get(source) ?? 0) + 1);
|
|
65
|
+
let listeners = this.listeners.get(source);
|
|
66
|
+
if (!listeners) {
|
|
67
|
+
listeners = new Set();
|
|
68
|
+
this.listeners.set(source, listeners);
|
|
69
|
+
}
|
|
70
|
+
listeners.add(listener);
|
|
71
|
+
}
|
|
67
72
|
return () => {
|
|
68
|
-
|
|
73
|
+
for (const source of uniqueSources) {
|
|
74
|
+
const listeners = this.listeners.get(source);
|
|
75
|
+
listeners?.delete(listener);
|
|
76
|
+
if (listeners?.size === 0)
|
|
77
|
+
this.listeners.delete(source);
|
|
78
|
+
const count = (this.activeSources.get(source) ?? 1) - 1;
|
|
79
|
+
if (count > 0)
|
|
80
|
+
this.activeSources.set(source, count);
|
|
81
|
+
else
|
|
82
|
+
this.activeSources.delete(source);
|
|
83
|
+
}
|
|
84
|
+
this.evictUnobservedSources();
|
|
69
85
|
};
|
|
70
86
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
reset() {
|
|
88
|
+
this.versions.clear();
|
|
89
|
+
this.listeners.clear();
|
|
90
|
+
this.activeSources.clear();
|
|
91
|
+
}
|
|
92
|
+
evictUnobservedSources() {
|
|
93
|
+
while (this.versions.size > MAX_TRACKED_SOURCES) {
|
|
94
|
+
let evicted = false;
|
|
95
|
+
for (const source of this.versions.keys()) {
|
|
96
|
+
if (this.activeSources.has(source))
|
|
97
|
+
continue;
|
|
98
|
+
this.versions.delete(source);
|
|
99
|
+
evicted = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (!evicted)
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
74
105
|
}
|
|
75
106
|
}
|
|
76
107
|
const store = new ChangeVersionStore();
|
|
@@ -110,7 +141,9 @@ export function getChangeVersion(source) {
|
|
|
110
141
|
* ```
|
|
111
142
|
*/
|
|
112
143
|
export function useChangeVersion(source) {
|
|
113
|
-
|
|
144
|
+
const subscribe = useCallback((listener) => store.subscribe([source], listener), [source]);
|
|
145
|
+
const getSnapshot = useCallback(() => store.get(source), [source]);
|
|
146
|
+
return useSyncExternalStore(subscribe, getSnapshot, ZERO_SNAPSHOT);
|
|
114
147
|
}
|
|
115
148
|
/**
|
|
116
149
|
* Convenience for queries that should refetch on multiple sources — returns
|
|
@@ -123,12 +156,25 @@ export function useChangeVersion(source) {
|
|
|
123
156
|
* ```
|
|
124
157
|
*/
|
|
125
158
|
export function useChangeVersions(sources) {
|
|
126
|
-
|
|
159
|
+
const sourceKey = sources
|
|
160
|
+
.map((source) => `${source.length}:${source}`)
|
|
161
|
+
.join("\u0001");
|
|
162
|
+
const stableSources = useMemo(() => {
|
|
163
|
+
const uniqueSources = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
for (const source of sources) {
|
|
166
|
+
if (seen.has(source))
|
|
167
|
+
continue;
|
|
168
|
+
seen.add(source);
|
|
169
|
+
uniqueSources.push(source);
|
|
170
|
+
}
|
|
171
|
+
return uniqueSources;
|
|
172
|
+
}, [sourceKey]);
|
|
173
|
+
const subscribe = useCallback((listener) => store.subscribe(stableSources, listener), [stableSources]);
|
|
174
|
+
const getSnapshot = useCallback(() => stableSources.reduce((sum, src) => sum + store.get(src), 0), [stableSources]);
|
|
175
|
+
return useSyncExternalStore(subscribe, getSnapshot, ZERO_SNAPSHOT);
|
|
127
176
|
}
|
|
128
177
|
/** Internal test helper — reset all counters. Do not use in app code. */
|
|
129
178
|
export function _resetChangeVersionStoreForTests() {
|
|
130
|
-
|
|
131
|
-
store.versions.clear();
|
|
132
|
-
// @ts-expect-error reaching past private to clear state in tests
|
|
133
|
-
store.cachedSnapshots.clear();
|
|
179
|
+
store.reset();
|
|
134
180
|
}
|