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
1 change: 1 addition & 0 deletions .github/workflows/deployment-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ concurrency:
jobs:
release:
name: Create tag and release
if: github.repository == 'homarr-labs/homarr'
runs-on: ubuntu-latest
timeout-minutes: 5
env:
Expand Down
149 changes: 149 additions & 0 deletions .github/workflows/deployment-fork-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
name: "[Fork] Build & publish HaLOS image"

# Builds a multi-arch container image for the hatlabs fork and publishes it to
# ghcr.io/hatlabs/homarr. See FORK.md for the tag scheme.
#
# Triggered by:
# - pushing a tag matching v*-halos.* (release build)
# - workflow_dispatch (manual rebuild of an existing ref)

on:
push:
tags:
- "v*-halos.*"
workflow_dispatch:
inputs:
tag:
description: "Image tag to publish (e.g. v1.59.3-halos.1). Defaults to the ref name."
required: false
type: string

permissions:
contents: read
packages: write

env:
REGISTRY: ghcr.io
GHCR_REPO: ghcr.io/${{ github.repository }}
SKIP_ENV_VALIDATION: "true"
TURBO_TELEMETRY_DISABLED: "1"

concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}

jobs:
resolve-tag:
name: Resolve image tag
if: github.repository == 'hatlabs/homarr'
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.pick.outputs.tag }}
is_release: ${{ steps.pick.outputs.is_release }}
steps:
- id: pick
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF#refs/tags/}"
is_release=true
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
is_release=true
else
tag="$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/-/g')"
is_release=false
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "is_release=$is_release" >> "$GITHUB_OUTPUT"

build-amd64:
name: Build amd64 image
needs: resolve-tag
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6

- uses: docker/metadata-action@v6
id: meta
with:
images: ${{ env.GHCR_REPO }}

- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/setup-buildx-action@v4

- id: build
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
env:
SKIP_ENV_VALIDATION: "true"

build-arm64:
name: Build arm64 image
needs: resolve-tag
runs-on: ubuntu-24.04-arm
timeout-minutes: 45
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6

- uses: docker/metadata-action@v6
id: meta
with:
images: ${{ env.GHCR_REPO }}

- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/setup-buildx-action@v4

- id: build
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
env:
SKIP_ENV_VALIDATION: "true"

publish:
name: Publish multi-arch manifest
needs: [resolve-tag, build-amd64, build-arm64]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Publish tag
run: |
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:${{ needs.resolve-tag.outputs.tag }} \
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}

- name: Update latest-halos
if: needs.resolve-tag.outputs.is_release == 'true'
run: |
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:latest-halos \
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
1 change: 1 addition & 0 deletions .github/workflows/deployment-weekly-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ permissions:

jobs:
create-and-merge-pr:
if: github.repository == 'homarr-labs/homarr'
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
Expand Down
76 changes: 76 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# `hatlabs/homarr` fork

This repository is a fork of [`homarr-labs/homarr`](https://github.com/homarr-labs/homarr)
maintained by Hat Labs to ship the Homarr container that powers
[HaLOS](https://halos.fi).

The fork carries a small set of patches that have not yet landed upstream.
Patches are kept on top of clean upstream release tags so they can be carried
forward by rebase rather than merge.

## Tag scheme

Fork release images are tagged as `v<upstream>-halos.<n>`:

- `<upstream>` — the upstream Homarr release the fork was rebased onto
(e.g. `v1.59.3`).
- `<n>` — fork iteration number (1-based) on top of that upstream release.

Example: `v1.59.3-halos.1` is the first fork release built on top of
upstream `v1.59.3`.

When upstream cuts a new release the fork rebases its branch onto it and the
counter resets:

```
v1.59.3-halos.1 first fork release on v1.59.3
v1.59.3-halos.2 second fork release (still on v1.59.3)
v1.59.4-halos.1 first fork release after rebasing onto v1.59.4
```

## Container images

Images are built by `.github/workflows/deployment-fork-image.yml` and pushed to
`ghcr.io/hatlabs/homarr`:

- `ghcr.io/hatlabs/homarr:v<upstream>-halos.<n>` — the immutable build per
fork release tag.
- `ghcr.io/hatlabs/homarr:latest-halos` — moves with the most recent fork
release.

The workflow is triggered by pushing a `v*-halos.*` tag, or manually via
`workflow_dispatch` with a tag argument.

## Upstream workflow

The upstream `deployment-docker-image.yml` and `deployment-weekly-release.yml`
workflows are guarded with `if: github.repository == 'homarr-labs/homarr'` so
they no-op on the fork. This keeps the upstream files close to verbatim and
minimizes rebase conflicts when pulling new upstream commits.

## Carried patches

Each fork branch should land via a normal hatlabs internal PR. The eventual
upstream PR (where applicable) is prepared as a separate, narrower branch
rebased onto the upstream merge target (`dev`).

### Commit hygiene for upstream-bound branches

Fork branches that are intended to also land upstream must keep
**fork-only** changes in dedicated commits, separate from the
upstream-relevant change. Fork-only material includes:

- `FORK.md`
- `.github/workflows/deployment-fork-image.yml`
- The `if: github.repository == 'homarr-labs/homarr'` guards added to
upstream workflows
- `docs/halos/` — fork-specific notes and learnings (organised under
`learnings/` with YAML frontmatter; relevant when implementing or
debugging fork-side patterns)
- Anything else that only makes sense in the `hatlabs/homarr` context

This way the upstream PR can be prepared by cherry-picking the
upstream-relevant commits only — typically a single contiguous range —
without manual file-level surgery. Convention: one leading commit
(`ci(fork): …`) carries every fork-only file; subsequent commits carry
the upstream-bound changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
---
title: Selectively rethrow tRPC errors in widgets inside an ErrorBoundary
date: 2026-04-30
module: packages/widgets/src/app
problem_type: best_practice
component: tooling
severity: medium
applies_when:
- "Widget uses tRPC + react-query inside a React ErrorBoundary"
- "One specific TRPCError code is a normal/expected condition, not a real failure"
- "Other error codes from the same query should still surface as the widget's error UI"
- "Replacing useSuspenseQuery with useQuery to gain access to query.error before render"
tags:
- trpc
- react-query
- error-boundary
- use-suspense-query
- use-query
- widget-pattern
- graceful-degradation
---

# Selectively rethrow tRPC errors in widgets inside an ErrorBoundary

## Context

The fork adds path-only hrefs (`/cockpit/`) so app cards work across multiple origins (mDNS, VPN FQDN, raw LAN IP). The browser resolves them against whatever origin the user is currently on. The server cannot follow that href to ping the app — synthesising an absolute URL from `X-Forwarded-Host` would be a header-spoofing / SSRF surface — so the ping router throws `TRPCError({code: "CONFLICT"})` when no explicit `pingUrl` is configured. That is a *valid* config, not a failure.

The friction: Homarr's ping indicator was originally written with `useSuspenseQuery`, and the widget sits inside a parent React `ErrorBoundary`. Every thrown tRPC error — including this expected CONFLICT — escaped Suspense and replaced the entire app card with a loud "Try again" error panel. Genuine misconfig (FORBIDDEN, NOT_FOUND) deserves that treatment; an intentionally non-pingable app does not.

## Guidance

When a tRPC query lives inside an outer error boundary and *some* of its error codes represent expected, normal configuration rather than faults, switch the call site from `useSuspenseQuery` to `useQuery` and discriminate on `error.data.code`:

1. **Replace `useSuspenseQuery` with `useQuery`** and disable retries (`retry: false`) so the expected error doesn't trigger backoff churn.
2. **Inspect `query.error.data?.code`** — render an in-place degraded UI for the known-good code(s), `throw query.error` for everything else so the outer boundary still catches genuine faults.
3. **Move the loading state inside the component.** Since you're no longer suspending, drop the parent `<Suspense fallback>` and render your own placeholder when `query.data` is undefined.
4. **Prefer derivation over `useState` + `useEffect`** when merging query data with an override stream (e.g. a websocket subscription): `const result = override ?? query.data ?? null`. This avoids the one-render lag described in tradeoffs.

The discriminator pattern:

```tsx
if (query.error) {
if (query.error.data?.code === "CONFLICT") {
return <DegradedView tooltip={query.error.message} />;
}
throw query.error; // FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR → boundary
}
```

## Why This Matters

- **Preserves the safety net.** The error boundary still catches genuinely broken state — auth failures, missing resources, server crashes — exactly as it did before. We narrow what's swallowed, we don't disable the boundary.
- **Turns expected config into normal UI.** A path-only href without `pingUrl` is a deliberate deployment choice, not a fault. Treating it as one is the bug; rendering a calm indeterminate dot is the fix.
- **Keeps the failure mode legible.** A typed code check (`error.data.code === "CONFLICT"`) is greppable, fails loudly if the server changes the code, and survives message rewording. Matching on `error.message` substrings would not.
- **Avoids worse alternatives.** Server-side "never throw, return null" loses the typed discriminator at the boundary and forces every consumer to recheck. An error-boundary reset button forces user interaction for a non-error.

## When to Apply

Apply this pattern when **all** of:

- The query runs inside a React `ErrorBoundary` (directly or via a parent widget framework).
- At least one tRPC error code returned by the procedure represents *expected, valid* runtime state (config-driven, not a fault).
- The component can render a meaningful degraded UI for that case.

Do **not** apply when:

- *Every* error from the procedure is genuinely a fault — `useSuspenseQuery` + boundary is simpler and correct.
- The component relies on React 18 streaming SSR for above-the-fold / SEO-critical content. `useQuery` is client-first; you lose the streaming integration. Dashboard widgets behind auth don't care; public landing-page content does.
- You'd be tempted to catch *all* errors generically. That defeats the boundary. Discriminate on a specific known code.

Alternatives considered and why they're worse:

- **Server returns `null` instead of throwing CONFLICT.** Loses the typed signal; every client must re-derive "is this a real null or a config-degraded null". Erodes the procedure's contract.
- **Error boundary with reset on CONFLICT.** Forces the user to click through a non-error. Also fragile: the boundary has to introspect the error to decide whether to auto-reset, which is the same discriminator logic in a worse place.
- **Try/catch around `useSuspenseQuery`.** Doesn't work — Suspense throws promises during render; you cannot catch the eventual error synchronously at the call site.

## Examples

Before — `packages/widgets/src/app/ping/ping-indicator.tsx`:

```tsx
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
{ id: appId },
{ refetchOnMount: false, refetchOnWindowFocus: false },
);
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
```

…wrapped at the call site in `packages/widgets/src/app/component.tsx` with `<Suspense fallback={<PingDot icon={IconLoader} … />}>`.

After:

```tsx
const query = clientApi.widget.app.ping.useQuery(
{ id: appId },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
},
);

const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(
query.data ?? null,
);

useEffect(() => {
if (query.data) setPingResult(query.data);
}, [query.data]);

clientApi.widget.app.updatedPing.useSubscription(
{ id: appId },
{ onData(data) { setPingResult(data); } },
);

// Apps without a server-pingable URL (e.g. path-only href without an explicit
// pingUrl) yield a CONFLICT. Render an indeterminate dot for that case so the
// card stays usable. Other tRPC errors (FORBIDDEN, NOT_FOUND) still bubble to
// the widget error boundary as before.
if (query.error) {
if (query.error.data?.code === "CONFLICT") {
return <PingDot icon={IconLoader} color="blue" tooltip={query.error.message} />;
}
throw query.error;
}

if (!pingResult) {
return <PingDot icon={IconLoader} color="blue" tooltip="Pinging…" />;
}
```

In `component.tsx`, the `<Suspense>` wrapper around `<PingIndicator>` is removed along with the now-unused `IconLoader` / `useI18n` / `PingDot` imports — loading state lives inside `PingIndicator` now.

A cleaner variant that avoids the one-render lag (see tradeoffs):

```tsx
const query = clientApi.widget.app.ping.useQuery(/* … */);
const [override, setOverride] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(null);

clientApi.widget.app.updatedPing.useSubscription(
{ id: appId },
{ onData: setOverride },
);

const pingResult = override ?? query.data ?? null;
// no useEffect needed; render is a pure derivation
```

## Tradeoffs

- **One-render visual lag on initial data.** With `useState(query.data ?? null)` + `useEffect`, the first render after `query.data` resolves shows the loading placeholder; the synced state lands one render later. Mitigation: derive `pingResult = override ?? query.data ?? null` instead of using `useState` + `useEffect`. The shipped code uses the useEffect form for symmetry with the subscription override; the derived form is preferable in new code.
- **Lost streaming-SSR integration.** `useSuspenseQuery` participates in React 18 streaming SSR; `useQuery` is client-first and renders the loading placeholder on the server. Irrelevant for authenticated dashboard widgets, material for public SEO-critical content.
- **`data` becomes nullable.** Consumers must handle `query.data === undefined` (loading) and the discriminated error case explicitly. The Suspense version made `data` non-null by construction; this version trades that ergonomic guarantee for the ability to keep rendering on expected errors.
Loading