@k2b/cloud 0.41.0 → 0.42.0

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.
Files changed (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,340 @@
1
+ import { type Job, type JobConfig, RetentionGapError, type Topic, type Worker } from "@k2b/sync";
2
+ import * as Y from "yjs";
3
+ import { logger } from "../services/logging";
4
+ import { compareStreamCursor } from "./cursor";
5
+ import { createDoc } from "./documents";
6
+ import { applySharedTextEvent, MalformedSharedTextEventError, SHARED_TEXT_NAME, sharedTextNodeId } from "./events";
7
+ import type { SharedTextContributor, SharedTextSaveInput, SharedTextStoredState, SharedTextSyncEvent } from "./types";
8
+
9
+ const ACK_WAIT_MS = 120_000;
10
+ const RECONCILE_LIMIT = 5_000;
11
+ // Each lookup is a short-lived tenant replay stopped after its first event; 16 is far below 5,000 latestCursor lookups per scan page.
12
+ // In steady state, only documents edited within the last snapshot delay or stalled documents remain behind.
13
+ const BACKLOG_LOOKUPS = 16;
14
+
15
+ export const sharedTextSnapshotJobConfig = (
16
+ config: Pick<JobConfig, "id" | "deadLetterRetention" | "terminalRetentionMs" | "dedupeWindowMs"> & {
17
+ retention: NonNullable<JobConfig["retention"]>;
18
+ },
19
+ ) =>
20
+ ({
21
+ id: config.id,
22
+ delivery: { ackWaitMs: ACK_WAIT_MS, maxAttempts: 20, backoffMs: [5_000] },
23
+ // Ordered by document across 8 partitions. Each process reconstructs one
24
+ // document at a time, keeping its memory budget fixed while a fleet can
25
+ // save up to 8 documents concurrently. Changing the count is a resource change.
26
+ ordering: { mode: "partitioned" as const, partitions: 8 },
27
+ retention: config.retention,
28
+ terminalRetentionMs: config.terminalRetentionMs ?? 30 * 24 * 60 * 60 * 1000,
29
+ // 256 dead letters at Sync's 128 KiB payload limit plus its 4 KiB of
30
+ // dead-letter headroom, the depth Sync gives a job by default. A snapshot is
31
+ // dead-lettered only for incomplete or malformed history or after 20 failed
32
+ // attempts; each one carries a document ID and a cursor of a few hundred bytes.
33
+ deadLetterRetention: config.deadLetterRetention ?? { maxBytes: 256 * (128 * 1024 + 4096) },
34
+ ...(config.dedupeWindowMs === undefined ? {} : { dedupeWindowMs: config.dedupeWindowMs }),
35
+ }) satisfies JobConfig;
36
+
37
+ export type SharedTextSnapshotRequest = { docId: string; targetCursor: string; reason: string };
38
+ export type SharedTextSnapshotStore = {
39
+ /** Stable keyset order by docId; include only documents that take snapshots. */
40
+ list(options: { limit: number; after?: string; signal?: AbortSignal }): Promise<Array<{ docId: string; cursor: string | null }>>;
41
+ /** Anchor legacy cursors to this log before returning. */
42
+ load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
43
+ /**
44
+ * Atomically fence restoreRevision and non-newer cursors. Apply projections
45
+ * only when the write was accepted, never when overtaken. Return normally
46
+ * when covered, gone or locked; throw after a restore if not already covered
47
+ * so the job retries from the current snapshot.
48
+ */
49
+ save(docId: string, input: SharedTextSaveInput): Promise<void>;
50
+ /** Preserve every valid retained update, including pending structs/deletes;
51
+ * save at a captured head with history incomplete. Lost bytes remain lost. */
52
+ recover(docId: string, cause: RetentionGapError | MalformedSharedTextEventError, options: { signal?: AbortSignal }): Promise<unknown>;
53
+ /** Move an idle cursor only while from is still stored and no newer event exists. */
54
+ advance(docId: string, input: { from: string; to: string; latest: string | null }): Promise<boolean>;
55
+ };
56
+ export type SharedTextSnapshotBacklog = {
57
+ log: string;
58
+ measuredAt: Date;
59
+ documents: number;
60
+ oldest: null | {
61
+ docId: string;
62
+ sequence: number;
63
+ ageMs: number | null;
64
+ sequenceDistance: number;
65
+ historyGap?: boolean;
66
+ /** The lookup budget left documents that may contain an older update. */
67
+ partial?: true;
68
+ };
69
+ };
70
+ type SnapshotLog = {
71
+ id: string;
72
+ topic(): Pick<Topic<SharedTextSyncEvent>, "head" | "latestCursor" | "cursorSequence" | "cursorAt" | "replay">;
73
+ firstSequence(): Promise<number>;
74
+ };
75
+ type SnapshotOptions = { log: SnapshotLog; store: SharedTextSnapshotStore; logger?: ReturnType<typeof logger> };
76
+ type InputCodec<Input> = { encode(request: SharedTextSnapshotRequest): Input; decode(input: Input): SharedTextSnapshotRequest };
77
+ type SnapshotJob<Input> = () => Pick<Job<Input>, "submit" | "process">;
78
+
79
+ export function snapshots(
80
+ options: SnapshotOptions & { job: SnapshotJob<SharedTextSnapshotRequest>; input?: undefined },
81
+ ): ReturnType<typeof createSnapshots>;
82
+ export function snapshots<Input>(
83
+ options: SnapshotOptions & { job: SnapshotJob<Input>; input: InputCodec<Input> },
84
+ ): ReturnType<typeof createSnapshots>;
85
+ export function snapshots<Input>(
86
+ options: SnapshotOptions &
87
+ ({ job: SnapshotJob<SharedTextSnapshotRequest>; input?: undefined } | { job: SnapshotJob<Input>; input: InputCodec<Input> }),
88
+ ) {
89
+ if (options.input) return createSnapshots(options, options.job, options.input);
90
+ return createSnapshots(options, options.job, { encode: (request) => request, decode: (input) => input });
91
+ }
92
+
93
+ const createSnapshots = <Input>(
94
+ { log, store, logger: suppliedLogger }: SnapshotOptions,
95
+ job: SnapshotJob<Input>,
96
+ input: InputCodec<Input>,
97
+ ) => {
98
+ const output = suppliedLogger ?? logger("shared-text-snapshots");
99
+ let worker: Promise<Worker> | null = null;
100
+ let lastBacklog: SharedTextSnapshotBacklog | null = null;
101
+ const queue = async (request: SharedTextSnapshotRequest): Promise<void> => {
102
+ const sequence = log.topic().cursorSequence(request.targetCursor);
103
+ // A final unload at a newer cursor must not coalesce with an already settling snapshot.
104
+ await job().submit({ key: `${request.docId}:${sequence}`, coalesce: true, orderingKey: request.docId, input: input.encode(request) });
105
+ };
106
+ const start = async (): Promise<void> => {
107
+ if (worker) {
108
+ await worker;
109
+ return;
110
+ }
111
+ worker = job().process(
112
+ {
113
+ concurrency: 1,
114
+ onError: async ({ error, context }) => {
115
+ output.error("Snapshot job failed; missing or malformed history requires attention", {
116
+ docId: input.decode(context.input).docId,
117
+ error: error instanceof Error ? error.message : String(error),
118
+ attempt: context.attempt,
119
+ });
120
+ if (error instanceof RetentionGapError)
121
+ return {
122
+ action: "dead_letter",
123
+ reason: "Document history is incomplete; retained updates and pending dependencies were recovered",
124
+ };
125
+ if (error instanceof MalformedSharedTextEventError)
126
+ return {
127
+ action: "dead_letter",
128
+ reason: `Retained event cannot be applied; valid updates and pending dependencies were recovered: ${error.message}`,
129
+ };
130
+ return { action: "retry" };
131
+ },
132
+ },
133
+ async (context) => {
134
+ const request = input.decode(context.input);
135
+ const { docId } = request;
136
+ let heartbeatError: unknown;
137
+ const timer = setInterval(() => {
138
+ void context.heartbeat().catch((error) => {
139
+ heartbeatError = error;
140
+ });
141
+ }, 15_000);
142
+ try {
143
+ const topic = log.topic();
144
+ const initial = await store.load(docId, { signal: context.signal });
145
+ // Head BEFORE the document's latest event: once it is applied up to
146
+ // that event, the head is a valid (fresher) cursor for the snapshot.
147
+ const head = await topic.head();
148
+ // Cover queued older cursors in one replay; later jobs skip already saved state.
149
+ const target = await topic.latestCursor({ tenantId: docId });
150
+ if (!target) {
151
+ output.warn("Snapshot skipped: no retained events for the note", { docId, requestedCursor: request.targetCursor });
152
+ return;
153
+ }
154
+ const requestedAt = Date.now();
155
+ if (!initial || (initial.cursor && compareStreamCursor(initial.cursor, target) >= 0)) return;
156
+ const doc = createDoc(initial);
157
+ const contributors = new Map<string, SharedTextContributor>();
158
+ try {
159
+ let reachedTarget = false;
160
+ try {
161
+ for await (const event of topic.replay({
162
+ tenantId: docId,
163
+ after: initial.cursor ?? topic.cursorAt(0),
164
+ until: target,
165
+ signal: context.signal,
166
+ })) {
167
+ applySharedTextEvent(doc, event, docId);
168
+ if (event.data.actor)
169
+ contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
170
+ ...event.data.actor,
171
+ lastContributedAt: event.publishedAt,
172
+ });
173
+ reachedTarget = event.cursor === target;
174
+ }
175
+ } catch (error) {
176
+ // Preserve all valid retained updates and unresolved dependencies;
177
+ // record the missing/invalid history once in the DLQ after recovery.
178
+ if (error instanceof RetentionGapError || error instanceof MalformedSharedTextEventError)
179
+ await store.recover(docId, error, { signal: context.signal });
180
+ throw error;
181
+ }
182
+ context.signal.throwIfAborted();
183
+ if (heartbeatError) throw heartbeatError;
184
+ if (!reachedTarget) throw new Error("Snapshot replay did not reach its target");
185
+ await context.heartbeat();
186
+ context.signal.throwIfAborted();
187
+ await store.save(docId, {
188
+ state: Y.encodeStateAsUpdate(doc),
189
+ text: doc.getText(SHARED_TEXT_NAME).toString(),
190
+ cursor: compareStreamCursor(head, target) > 0 ? head : target,
191
+ restoreRevision: initial.restoreRevision,
192
+ requestedAt,
193
+ actor: null,
194
+ contributors: [...contributors.values()],
195
+ });
196
+ } finally {
197
+ doc.destroy();
198
+ }
199
+ } finally {
200
+ clearInterval(timer);
201
+ }
202
+ },
203
+ );
204
+ try {
205
+ await worker;
206
+ output.debug("Snapshot worker started", { nodeId: sharedTextNodeId });
207
+ } catch (error) {
208
+ worker = null;
209
+ throw error;
210
+ }
211
+ };
212
+ const stop = async (): Promise<void> => {
213
+ const active = await worker;
214
+ worker = null;
215
+ active?.stop();
216
+ await active?.drain({ timeoutMs: ACK_WAIT_MS });
217
+ };
218
+ const reconcile = async (config: { signal?: AbortSignal; heartbeat?: () => Promise<void> } = {}) => {
219
+ const topic = log.topic();
220
+ let after: string | undefined;
221
+ let checked = 0;
222
+ let queued = 0;
223
+ let advanced = 0;
224
+ let lastHeadSeq = 0;
225
+ const backlogCandidates: Array<{ docId: string; stored: string; storedSeq: number; latest: string }> = [];
226
+ let unretainedMinSeq: number | null = null;
227
+ while (true) {
228
+ config.signal?.throwIfAborted();
229
+ const candidates = await store.list({ limit: RECONCILE_LIMIT, after, signal: config.signal });
230
+ // Head BEFORE each document's latest event: an idle document is covered up to it.
231
+ const head = await topic.head();
232
+ const headSeq = topic.cursorSequence(head);
233
+ lastHeadSeq = headSeq;
234
+ const firstSeq = await log.firstSequence();
235
+ const refreshBelow = firstSeq + Math.floor((headSeq - firstSeq) / 2);
236
+ for (const candidate of candidates) {
237
+ config.signal?.throwIfAborted();
238
+ if (checked % 200 === 0) await config.heartbeat?.();
239
+ checked++;
240
+ let stored = candidate.cursor;
241
+ let shared = false;
242
+ if (stored) {
243
+ try {
244
+ topic.cursorSequence(stored);
245
+ shared = true;
246
+ } catch {
247
+ /* Legacy cursor: load anchors it. */
248
+ }
249
+ }
250
+ if (!shared) {
251
+ const loaded = await store.load(candidate.docId, { signal: config.signal });
252
+ if (!loaded) continue;
253
+ stored = loaded.cursor;
254
+ }
255
+ const latest = await topic.latestCursor({ tenantId: candidate.docId });
256
+ // A document without a stored cursor replays from the log's start, like the job does.
257
+ if (latest && (!stored || compareStreamCursor(latest, stored) > 0)) {
258
+ await queue({ docId: candidate.docId, targetCursor: latest, reason: "reconcile" });
259
+ queued++;
260
+ const from = stored ?? topic.cursorAt(0);
261
+ const storedSeq = topic.cursorSequence(from);
262
+ const index = backlogCandidates.findIndex((entry) => entry.storedSeq > storedSeq);
263
+ if (backlogCandidates.length === BACKLOG_LOOKUPS && index === -1) {
264
+ unretainedMinSeq = Math.min(unretainedMinSeq ?? storedSeq, storedSeq);
265
+ } else {
266
+ if (backlogCandidates.length === BACKLOG_LOOKUPS) {
267
+ const evicted = backlogCandidates.pop()!;
268
+ unretainedMinSeq = Math.min(unretainedMinSeq ?? evicted.storedSeq, evicted.storedSeq);
269
+ }
270
+ backlogCandidates.splice(index === -1 ? backlogCandidates.length : index, 0, {
271
+ docId: candidate.docId,
272
+ stored: from,
273
+ storedSeq,
274
+ latest,
275
+ });
276
+ }
277
+ continue;
278
+ }
279
+ if (!stored) continue;
280
+ if (topic.cursorSequence(stored) < refreshBelow && (await store.advance(candidate.docId, { from: stored, to: head, latest })))
281
+ advanced++;
282
+ }
283
+ if (candidates.length < RECONCILE_LIMIT) break;
284
+ after = candidates.at(-1)!.docId;
285
+ }
286
+ const report: SharedTextSnapshotBacklog = { log: log.id, measuredAt: new Date(), documents: queued, oldest: null };
287
+ for (const [index, candidate] of backlogCandidates.entries()) {
288
+ if (report.oldest && candidate.storedSeq + 1 >= report.oldest.sequence) break;
289
+ // The first lookup runs in the scan's last heartbeat interval, each later one
290
+ // renews it. A lost lease ends the run; only the lookup itself fails softly.
291
+ if (index > 0) await config.heartbeat?.();
292
+ const lookup = new AbortController();
293
+ const signal = config.signal ? AbortSignal.any([lookup.signal, config.signal]) : lookup.signal;
294
+ try {
295
+ let found = false;
296
+ for await (const event of topic.replay({ tenantId: candidate.docId, after: candidate.stored, until: candidate.latest, signal })) {
297
+ found = true;
298
+ if (!report.oldest || event.sequence < report.oldest.sequence)
299
+ report.oldest = {
300
+ docId: candidate.docId,
301
+ sequence: event.sequence,
302
+ ageMs: report.measuredAt.getTime() - event.publishedAt.getTime(),
303
+ sequenceDistance: lastHeadSeq - event.sequence,
304
+ };
305
+ break;
306
+ }
307
+ if (!found) throw new Error("Snapshot backlog replay returned no retained event");
308
+ } catch (error) {
309
+ config.signal?.throwIfAborted();
310
+ output.warn("Could not read shared text snapshot backlog history", {
311
+ docId: candidate.docId,
312
+ error: error instanceof Error ? error.message : String(error),
313
+ });
314
+ report.oldest = {
315
+ docId: candidate.docId,
316
+ sequence: candidate.storedSeq + 1,
317
+ ageMs: null,
318
+ sequenceDistance: lastHeadSeq - candidate.storedSeq,
319
+ historyGap: error instanceof RetentionGapError,
320
+ };
321
+ break;
322
+ } finally {
323
+ lookup.abort();
324
+ }
325
+ if (
326
+ candidate === backlogCandidates.at(-1) &&
327
+ report.oldest &&
328
+ unretainedMinSeq !== null &&
329
+ unretainedMinSeq + 1 < report.oldest.sequence
330
+ )
331
+ report.oldest.partial = true;
332
+ }
333
+ config.signal?.throwIfAborted();
334
+ output.info("Snapshot reconcile finished", { checked, queued, advanced });
335
+ output.info("Shared text snapshot backlog", report);
336
+ lastBacklog = report;
337
+ return { checked, queued, advanced };
338
+ };
339
+ return { queue, start, stop, reconcile, backlog: () => lastBacklog };
340
+ };
@@ -3,14 +3,16 @@ import type { JSX } from "solid-js/jsx-runtime";
3
3
  import type { PanelConsentSnapshot } from "../contracts/panel-context";
4
4
  import type { RailSnapshot } from "../contracts/rail-preferences";
5
5
  import { getLocale } from "../server/locale";
6
+ import type { AuthContext } from "../server/middleware/auth";
6
7
  import type { LayoutAnnouncementsState } from "../server/middleware/settings";
7
8
  import AdminSidebar from "./AdminSidebar";
8
9
  import Layout from "./Layout";
9
10
  import { platformMessages } from "./platform-messages";
10
- import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
11
+ import { getDisplayRuntimeContext, type RuntimeContext } from "./runtime";
11
12
 
12
13
  type Breadcrumb = { title: string; href?: string };
13
14
  type AdminLayoutContext = {
15
+ get(key: "actor"): AuthContext["Variables"]["actor"] | undefined;
14
16
  get(key: "panelConsent"): PanelConsentSnapshot | undefined;
15
17
  get(key: "railPreferences"): RailSnapshot | undefined;
16
18
  get(key: "user"): any;
@@ -34,7 +36,7 @@ export default function AdminLayout(props: Props) {
34
36
  const t = platformMessages.resolve([locale]).t;
35
37
  const url = new URL(c.req.raw.url);
36
38
  const currentPath = `${url.pathname}${url.search}`;
37
- const runtime = getLocalizedRuntimeContext(c);
39
+ const runtime = getDisplayRuntimeContext(c);
38
40
  // Admin is the area's root, as an app's root is: the rail and the admin sidebar lead there. So it is no level
39
41
  // above an admin page, and every admin page shows its title alone, whichever app owns it.
40
42
  const overview = title === "Overview" || title === t.overview;
@@ -57,6 +57,9 @@ function BarNavigation(props: { currentPath: string }) {
57
57
  syncLocation();
58
58
  window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
59
59
  window.addEventListener("popstate", syncLocation);
60
+ window.addEventListener("ssr:nav-popstate", syncLocation);
61
+ window.addEventListener("ssr:nav-swapped", sync);
62
+ window.addEventListener("ssr:nav-swapped", syncLocation);
60
63
  window.addEventListener("hashchange", syncLocation);
61
64
  // In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
62
65
  // reports; where a browser lacks it, the bar follows at the next page load.
@@ -64,6 +67,9 @@ function BarNavigation(props: { currentPath: string }) {
64
67
  onCleanup(() => {
65
68
  window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
66
69
  window.removeEventListener("popstate", syncLocation);
70
+ window.removeEventListener("ssr:nav-popstate", syncLocation);
71
+ window.removeEventListener("ssr:nav-swapped", sync);
72
+ window.removeEventListener("ssr:nav-swapped", syncLocation);
67
73
  window.removeEventListener("hashchange", syncLocation);
68
74
  window.navigation?.removeEventListener("currententrychange", syncLocation);
69
75
  });
@@ -10,6 +10,7 @@ import { PANEL_HELP_ID_MAX_LENGTH, PanelPageContextSchema } from "../contracts/p
10
10
  import { isPwaShellAvailable } from "../contracts/pwa";
11
11
  import { defaultRailPreferences } from "../contracts/rail-preferences";
12
12
  import { hasRole, type User } from "../contracts/shared";
13
+ import { isAppSessionActor } from "../server/actor";
13
14
  import { getLocale } from "../server/locale";
14
15
  import { getDateConfig } from "../server/time";
15
16
  import { dates, resolveHomePath } from "../shared";
@@ -27,14 +28,15 @@ import { HELP_VIEW_LIMIT } from "./layout-help-start";
27
28
  import type { LayoutBreadcrumb } from "./layout-runtime";
28
29
  import PageTrailRow from "./PageTrailRow";
29
30
  import PanelSlot from "./PanelSlot";
31
+ import { getPageSwapBuild, pageSwapSignature } from "./page-swap";
30
32
  import { derivePageTrail } from "./page-trail";
31
33
  import { platformMessages } from "./platform-messages";
32
34
  import { sortRailApps } from "./rail-navigation";
33
35
  import { requestPath } from "./request-path";
34
- import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
36
+ import { getDisplayRuntimeContext, getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
35
37
  import ShellFocusToggle from "./ShellFocusToggle";
36
38
  import ShellRuntime from "./ShellRuntime.island";
37
- import { type ShellData, ShellDataProvider } from "./shell-data";
39
+ import { type ShellData, ShellDataProvider, ShellDataScript } from "./shell-data";
38
40
 
39
41
  // Types
40
42
  type Breadcrumb = LayoutBreadcrumb;
@@ -164,7 +166,9 @@ export default function Layout(props: LayoutProps) {
164
166
  const lang = getLocale(c);
165
167
  const t = platformMessages.resolve([lang]).t;
166
168
  const user = c.get("user");
167
- const providers = user && props.panel !== false ? visiblePanelProviders(runtime.apps, user, lang) : [];
169
+ const displayIds = new Set(getDisplayRuntimeContext(c).apps.map((app) => app.id));
170
+ const displayApps = runtime.apps.filter((app) => displayIds.has(app.id));
171
+ const providers = user && props.panel !== false ? visiblePanelProviders(displayApps, user, lang) : [];
168
172
  const panelState = readPanelCookie(
169
173
  providers.length ? cookie : undefined,
170
174
  providers.map((provider) => provider.id),
@@ -178,12 +182,14 @@ export default function Layout(props: LayoutProps) {
178
182
  readAppWorkspaceLayoutCookie(cookie, currentApp?.id),
179
183
  workspaceSidebarCollapsible,
180
184
  );
181
- const { primary: primaryApps, more: moreApps } = buildNavLinks(runtime.apps, user, lang);
185
+ const { primary: primaryApps, more: moreApps } = buildNavLinks(displayApps, user, lang);
182
186
  const allApps = sortRailApps([...primaryApps, ...moreApps], lang);
183
187
  const visibleAppIds = new Set(allApps.map((app) => app.id));
184
188
  const searchLinks = Object.fromEntries(
185
- runtime.apps.flatMap((app) => (visibleAppIds.has(app.id) && app.searchLinks?.length ? [[app.id, app.searchLinks] as const] : [])),
189
+ displayApps.flatMap((app) => (visibleAppIds.has(app.id) && app.searchLinks?.length ? [[app.id, app.searchLinks] as const] : [])),
186
190
  );
191
+ const swapBuild = getPageSwapBuild(c);
192
+ const swap = swapBuild !== undefined && !focusMode && !!currentRoute?.prefix;
187
193
  const settings = c.get("settings");
188
194
  const announcements = c.get("announcements");
189
195
  const appName = settings?.app?.name || "Cloud";
@@ -212,7 +218,7 @@ export default function Layout(props: LayoutProps) {
212
218
  // The app bar and the app's navigation hidden with Cmd/Ctrl+\ or the sidebar buttons; phones ignore it in CSS.
213
219
  const shellFocus = showRail && readShellFocusCookie(cookie);
214
220
  // The profile menu offers the mobile app only while the installation runs it.
215
- const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
221
+ const appHref = user && isPwaShellAvailable(displayApps) ? "/me/app" : undefined;
216
222
  const profileName = user?.displayName || user?.uid || "?";
217
223
  const profileAvatarSrc =
218
224
  user?.avatarHash && user.id
@@ -271,6 +277,30 @@ export default function Layout(props: LayoutProps) {
271
277
  searchLinks,
272
278
  appId: currentApp?.id,
273
279
  };
280
+ // What the kept shell shows outside the column: when any of it differs, the next page loads as a document.
281
+ const signature = swap
282
+ ? pageSwapSignature({
283
+ app: currentApp?.id,
284
+ kind: user ? "app" : "anonymous",
285
+ user: user?.id ?? null,
286
+ appSession: isAppSessionActor(c.get("actor")),
287
+ locale: lang,
288
+ theme: c.get("page").theme,
289
+ build: swapBuild,
290
+ shell: {
291
+ // The document's <html> theme and the profile menu keep this preference.
292
+ themePreference: theme,
293
+ providers,
294
+ apps: allApps,
295
+ legalLinks,
296
+ searchLinks,
297
+ home: shellData.home,
298
+ profile: shellData.profile,
299
+ announcements: user ? (announcements?.latestAnnouncementVersion ?? null) : null,
300
+ help: registeredHelp?.manifestHash ?? (layoutHelpFailed(c) ? "failed" : null),
301
+ },
302
+ })
303
+ : undefined;
274
304
  if (focusMode) {
275
305
  return (
276
306
  <LocaleProvider locale={lang}>
@@ -310,8 +340,11 @@ export default function Layout(props: LayoutProps) {
310
340
  return (
311
341
  <LocaleProvider locale={lang}>
312
342
  <div
313
- class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
343
+ class={`cloud-app-canvas relative flex w-full ${fullPage && !swap ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
314
344
  style={canvasStyle}
345
+ data-nav-shell={signature}
346
+ data-nav-scope={swap ? currentRoute?.prefix : undefined}
347
+ data-nav-attributes={swap ? "data-layout-full-page data-workspace-sidebar-collapsed" : undefined}
315
348
  data-panel={panelState.open ? "open" : undefined}
316
349
  data-app-id={currentApp?.id}
317
350
  data-app-base={currentRoute?.prefix}
@@ -334,13 +367,18 @@ export default function Layout(props: LayoutProps) {
334
367
  ? { entries: announcements.announcements, latestVersion: announcements.latestAnnouncementVersion }
335
368
  : undefined
336
369
  }
337
- banners={user && announcements?.banners.length ? true : undefined}
370
+ banners={user && (swap || announcements?.banners.length) ? true : undefined}
338
371
  />
339
- <ShellDataProvider data={shellData}>
372
+ <ShellDataProvider data={shellData} skipScript={swap}>
340
373
  {showRail && <LayoutRail currentPath={requestPath(c)} panelOpen={panel ? panelState.open : undefined} />}
341
374
  {showRail && <ShellFocusToggle variant="float" />}
342
375
  {showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
343
- <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col" data-cloud-app-area="">
376
+ <div
377
+ class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col"
378
+ data-cloud-app-area=""
379
+ data-nav-root={swap ? "" : undefined}
380
+ >
381
+ {swap && <ShellDataScript data={shellData} />}
344
382
  <LayoutHeader
345
383
  panelOpen={panel ? panelState.open : undefined}
346
384
  authenticated={showRail}
@@ -353,19 +391,31 @@ export default function Layout(props: LayoutProps) {
353
391
  {user && <ExpiryWarnings user={user} dateConfig={dateConfig} locale={lang} />}
354
392
  <PageTrailProvider trail={pageTrail}>
355
393
  <PageTrailRow trail={pageTrail} />
356
- <main class={`layout-content-main min-h-0 min-w-0 flex-1 ${mainLayoutClass}`}>
394
+ <main
395
+ class={`layout-content-main min-h-0 min-w-0 flex-1 ${mainLayoutClass}`}
396
+ data-scroll-preserve={swap ? "cloud-main" : undefined}
397
+ data-scroll-page={swap ? "" : undefined}
398
+ >
357
399
  <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
358
400
  </main>
359
401
  </PageTrailProvider>
360
402
  {!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
403
+ {swap && (
404
+ <div data-print-fallback="">
405
+ <p>{t.printFallbackTitle}</p>
406
+ <p>{props.printHint ?? t.printFallbackHint}</p>
407
+ </div>
408
+ )}
361
409
  </div>
362
410
  {panel && <PanelSlot panel={panel} state={panelState} platform={shellData.platform} />}
363
411
  {showRail && <LayoutTabBar currentPath={requestPath(c)} />}
364
412
  </ShellDataProvider>
365
- <div data-print-fallback="">
366
- <p>{t.printFallbackTitle}</p>
367
- <p>{props.printHint ?? t.printFallbackHint}</p>
368
- </div>
413
+ {!swap && (
414
+ <div data-print-fallback="">
415
+ <p>{t.printFallbackTitle}</p>
416
+ <p>{props.printHint ?? t.printFallbackHint}</p>
417
+ </div>
418
+ )}
369
419
  </div>
370
420
  </LocaleProvider>
371
421
  );
@@ -100,7 +100,7 @@ if (!main) throw new Error("Missing Layout main");
100
100
  const embedded = params.has("embedded");
101
101
  const referenceHost = params.get("host");
102
102
  if (referenceHost === "grids" || referenceHost === "pulse") {
103
- // The Grids and Pulse query reference windows: one article beside the reference sidebar, without the Cloud shell.
103
+ // The Grids query reference windows: one article beside the reference sidebar, without the Cloud shell.
104
104
  const host = document.createElement("div");
105
105
  host.className = "h-dvh";
106
106
  document.body.replaceChildren(host);
@@ -1,4 +1,5 @@
1
1
  import type { HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
2
+ import { listenPopState } from "@k2b/ssr/nav";
2
3
  import { clipboard } from "@k2b/stdlib/solid";
3
4
  import {
4
5
  BottomSheet,
@@ -383,12 +384,12 @@ const HelpShell = (props: {
383
384
  // The hub keeps its search, as the reader's own Back button does. An unknown topic shows the hub, as the server does.
384
385
  else setView("hub");
385
386
  };
386
- if (props.syncPageUrl) window.addEventListener("popstate", onPopState);
387
+ const stopPopState = props.syncPageUrl ? listenPopState(onPopState) : undefined;
387
388
  onCleanup(() => {
388
389
  scrollArea?.removeEventListener("scroll", onScroll);
389
390
  window.removeEventListener("resize", onResize);
390
391
  if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame);
391
- window.removeEventListener("popstate", onPopState);
392
+ stopPopState?.();
392
393
  });
393
394
  });
394
395
  onCleanup(() => copyAllController?.abort());
@@ -1,6 +1,7 @@
1
1
  import { installAppWorkspaceController, installInputModeController, keyboardPlatform } from "@k2b/ui";
2
2
  import { onCleanup, onMount } from "solid-js";
3
3
  import { installPanelContextReceiver } from "../browser/panel-context-receiver";
4
+ import { hasPanelLeaveGuard, panelLinkDestination } from "../browser/panel-leave";
4
5
  import { syncTimezoneCookie } from "./timezone-cookie";
5
6
 
6
7
  export default function PanelRuntime(props: { provider?: string; scope?: string }) {
@@ -24,15 +25,18 @@ export default function PanelRuntime(props: { provider?: string; scope?: string
24
25
  };
25
26
  const close = () => window.parent.postMessage({ type: "cloud.panel.close" }, location.origin);
26
27
  const click = (event: MouseEvent) => {
27
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
28
- const anchor = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>("a[href]") : null;
29
- if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) return;
30
- const url = new URL(anchor.getAttribute("href")!, document.baseURI);
31
- if (url.origin !== location.origin) return;
28
+ const url = panelLinkDestination(event);
29
+ if (!url) return;
32
30
  const scope = props.scope?.replace(/\/$/, "");
33
- if (scope !== undefined && (url.pathname === scope || url.pathname.startsWith(`${scope}/`))) return;
31
+ const internal = scope !== undefined && (url.pathname === scope || url.pathname.startsWith(`${scope}/`));
32
+ if (!hasPanelLeaveGuard()) {
33
+ if (internal) return;
34
+ event.preventDefault();
35
+ window.parent.location.assign(url.href);
36
+ return;
37
+ }
34
38
  event.preventDefault();
35
- window.parent.location.assign(url.href);
39
+ window.parent.postMessage({ type: "cloud.panel.navigate", v: 1, href: url.href }, location.origin);
36
40
  };
37
41
  const key = (event: KeyboardEvent) => {
38
42
  if (event.isComposing || event.defaultPrevented || event.altKey || event.shiftKey) return;
@@ -6,7 +6,7 @@ import { visiblePwaParts } from "./app-navigation";
6
6
  import type { LayoutContext } from "./layout-context";
7
7
  import PwaRuntime from "./PwaRuntime.island";
8
8
  import { pwaMessages } from "./pwa-messages";
9
- import { getRuntimeContext } from "./runtime";
9
+ import { getDisplayRuntimeContext } from "./runtime";
10
10
  import TimezoneCookie from "./TimezoneCookie.island";
11
11
 
12
12
  /** Parts with their own tab; Start lists every part and doubles as "More". */
@@ -42,7 +42,7 @@ export default function PwaLayout(props: PwaLayoutProps) {
42
42
  c.header("Referrer-Policy", "no-referrer");
43
43
  c.header("Content-Security-Policy", "frame-ancestors 'none'");
44
44
 
45
- const parts = visiblePwaParts(getRuntimeContext(c).apps, user, locale);
45
+ const parts = visiblePwaParts(getDisplayRuntimeContext(c).apps, user, locale);
46
46
  const path = new URL(c.req.raw.url).pathname;
47
47
  const tabs = parts.slice(0, TAB_PARTS);
48
48
  const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
@@ -44,11 +44,17 @@ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapsho
44
44
  const stopBadges = watchAppBadges(props.apps.flatMap((app) => (app.badge ? [app.badge] : [])));
45
45
  window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
46
46
  window.addEventListener("popstate", syncLocation);
47
+ window.addEventListener("ssr:nav-popstate", syncLocation);
48
+ window.addEventListener("ssr:nav-swapped", sync);
49
+ window.addEventListener("ssr:nav-swapped", syncLocation);
47
50
  window.addEventListener("hashchange", syncLocation);
48
51
  onCleanup(() => {
49
52
  stopBadges();
50
53
  window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
51
54
  window.removeEventListener("popstate", syncLocation);
55
+ window.removeEventListener("ssr:nav-popstate", syncLocation);
56
+ window.removeEventListener("ssr:nav-swapped", sync);
57
+ window.removeEventListener("ssr:nav-swapped", syncLocation);
52
58
  window.removeEventListener("hashchange", syncLocation);
53
59
  });
54
60
  });