diff --git a/AGENTS.md b/AGENTS.md
index 92ca0e08b8..b67c800bc7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,7 +115,7 @@
# Layout & accessibility
- Build pages inside the shared `PageContainer`/`ContentPane` (`app/layouts/helpers.tsx`).
-- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically.
+- To put page-level buttons or pagination in the sticky footer at the bottom of `ContentPane`, wrap them in `PageFooter` (`app/components/PageFooter.tsx`), which portals its children there. `FullPageForm` actions and the `Pagination` component already do this.
- For global loading states, reuse `PageSkeleton`—it keeps the MSW banner and grid layout stable, and `skipPaths` lets you opt-out for routes with custom layouts (`app/components/PageSkeleton.tsx`).
- Enforce accessibility at the type level: use `AriaLabel` type from `app/ui/util/aria.ts` which requires exactly one of `aria-label` or `aria-labelledby` on custom interactive components.
diff --git a/app/components/PageActions.tsx b/app/components/PageActions.tsx
deleted file mode 100644
index 6960a286fe..0000000000
--- a/app/components/PageActions.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, you can obtain one at https://mozilla.org/MPL/2.0/.
- *
- * Copyright Oxide Computer Company
- */
-import tunnel from 'tunnel-rat'
-
-const Tunnel = tunnel()
-
-export const PageActions = Tunnel.In
-export const PageActionsTarget = Tunnel.Out
diff --git a/app/components/PageFooter.tsx b/app/components/PageFooter.tsx
new file mode 100644
index 0000000000..3a5453effb
--- /dev/null
+++ b/app/components/PageFooter.tsx
@@ -0,0 +1,26 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import type { ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { create } from 'zustand'
+
+/** The sticky footer element at the bottom of `ContentPane` */
+const useFooter = create<{ el: HTMLElement | null }>(() => ({ el: null }))
+
+/** Ref callback for the footer element */
+export const setPageFooter = (el: HTMLElement | null) => useFooter.setState({ el })
+
+/**
+ * Render page-level buttons or pagination in the sticky footer at the bottom
+ * of the content pane, outside the page's scrolling content.
+ */
+export function PageFooter({ children }: { children: ReactNode }) {
+ const footer = useFooter((s) => s.el)
+ // null on the first render of a ContentPane, before the footer has mounted
+ return footer && createPortal(children, footer)
+}
diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx
index f6b795bcce..6cbf7c3e62 100644
--- a/app/components/Pagination.tsx
+++ b/app/components/Pagination.tsx
@@ -5,21 +5,17 @@
*
* Copyright Oxide Computer Company
*/
-import tunnel from 'tunnel-rat'
-
import {
Pagination as UIPagination,
type PaginationProps as UIPaginationProps,
} from '~/ui/lib/Pagination'
-const Tunnel = tunnel()
+import { PageFooter } from './PageFooter'
export function Pagination(props: UIPaginationProps) {
return (
-
+
-
+
)
}
-
-Pagination.Target = Tunnel.Out
diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx
index d6090c0bf9..c8aed64d0b 100644
--- a/app/components/form/FullPageForm.tsx
+++ b/app/components/form/FullPageForm.tsx
@@ -16,7 +16,7 @@ import { flattenChildren, pluckFirstOfType } from '~/util/children'
import { classed } from '~/util/classed'
import { Form } from '../form/Form'
-import { PageActions } from '../PageActions'
+import { PageFooter } from '../PageFooter'
interface FullPageFormProps {
id: string
@@ -106,7 +106,7 @@ export function FullPageForm({
{!isSubmitSuccessful && }
{actions && (
-
+
{cloneElement(actions, {
formId: id,
@@ -115,7 +115,7 @@ export function FullPageForm({
error: error || submitError,
})}
-
+
)}
>
)
diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx
index 2a0815d8d8..75b814ed8a 100644
--- a/app/layouts/helpers.tsx
+++ b/app/layouts/helpers.tsx
@@ -7,8 +7,7 @@
*/
import { Outlet } from 'react-router'
-import { PageActionsTarget } from '~/components/PageActions'
-import { Pagination } from '~/components/Pagination'
+import { setPageFooter } from '~/components/PageFooter'
import { useScrollRestoration } from '~/hooks/use-scroll-restoration'
import { classed } from '~/util/classed'
@@ -38,10 +37,11 @@ export function ContentPane() {
-
+ {/* pagination and form actions portal in here through PageFooter */}
+
)
}
diff --git a/package-lock.json b/package-lock.json
index ce7c9faabc..884eff9a8e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,7 +50,6 @@
"shiki": "^4.5.0",
"simplebar-react": "^3.2.6",
"ts-pattern": "^5.8.0",
- "tunnel-rat": "^0.1.2",
"uplot": "^1.6.32",
"uplot-react": "^1.2.4",
"use-debounce": "^10.0.4",
@@ -7514,39 +7513,6 @@
"version": "1.14.1",
"license": "0BSD"
},
- "node_modules/tunnel-rat": {
- "version": "0.1.2",
- "license": "MIT",
- "dependencies": {
- "zustand": "^4.3.2"
- }
- },
- "node_modules/tunnel-rat/node_modules/zustand": {
- "version": "4.5.6",
- "license": "MIT",
- "dependencies": {
- "use-sync-external-store": "^1.2.2"
- },
- "engines": {
- "node": ">=12.7.0"
- },
- "peerDependencies": {
- "@types/react": ">=16.8",
- "immer": ">=9.0.6",
- "react": ">=16.8"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "immer": {
- "optional": true
- },
- "react": {
- "optional": true
- }
- }
- },
"node_modules/type-fest": {
"version": "5.2.0",
"dev": true,
diff --git a/package.json b/package.json
index 8ec296feaf..5e0593de8c 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,6 @@
"shiki": "^4.5.0",
"simplebar-react": "^3.2.6",
"ts-pattern": "^5.8.0",
- "tunnel-rat": "^0.1.2",
"uplot": "^1.6.32",
"uplot-react": "^1.2.4",
"use-debounce": "^10.0.4",
diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts
index a0eb9179f8..3b4aaa7625 100644
--- a/test/e2e/pagination.e2e.ts
+++ b/test/e2e/pagination.e2e.ts
@@ -69,3 +69,27 @@ test('pagination', async ({ page }) => {
await nextButton.click()
await expect(spinner).toBeHidden({ timeout: 10 }) // no spinner, cached page
})
+
+// Pagination and full-page form buttons are portaled into the same footer
+// element, which is replaced when the layout changes
+test('footer content follows navigation', async ({ page }) => {
+ const pagination = page.getByRole('navigation', { name: 'Pagination' })
+ const submit = page.getByRole('button', { name: 'Create instance' })
+
+ await page.goto('/projects')
+ await expect(pagination).toBeVisible()
+
+ // silo layout to project layout
+ await page.getByRole('link', { name: 'mock-project' }).click()
+ await expect(page.getByRole('heading', { name: 'Instances' })).toBeVisible()
+ await expect(pagination).toBeVisible()
+
+ await page.getByRole('link', { name: 'New Instance' }).click()
+ await expect(submit).toBeVisible()
+ await expect(pagination).toBeHidden()
+
+ await page.getByRole('button', { name: 'Cancel' }).click()
+ await expect(page.getByRole('heading', { name: 'Instances' })).toBeVisible()
+ await expect(pagination).toBeVisible()
+ await expect(submit).toBeHidden()
+})