Skip to content

Commit 0d283d8

Browse files
authored
Merge pull request #2 from hatlabs/feat/path-only-app-hrefs
feat: support path-only hrefs (upstream sync 1.59.3 → 1.60.0+)
2 parents e315557 + 9d1b707 commit 0d283d8

53 files changed

Lines changed: 899 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deployment-docker-image.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ concurrency:
3131
jobs:
3232
release:
3333
name: Create tag and release
34+
if: github.repository == 'homarr-labs/homarr'
3435
runs-on: ubuntu-latest
3536
timeout-minutes: 5
3637
env:
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
name: "[Fork] Build & publish HaLOS image"
2+
3+
# Builds a multi-arch container image for the hatlabs fork and publishes it to
4+
# ghcr.io/hatlabs/homarr. See FORK.md for the tag scheme.
5+
#
6+
# Triggered by:
7+
# - pushing a tag matching v*-halos.* (release build)
8+
# - workflow_dispatch (manual rebuild of an existing ref)
9+
10+
on:
11+
push:
12+
tags:
13+
- "v*-halos.*"
14+
workflow_dispatch:
15+
inputs:
16+
tag:
17+
description: "Image tag to publish (e.g. v1.59.3-halos.1). Defaults to the ref name."
18+
required: false
19+
type: string
20+
21+
permissions:
22+
contents: read
23+
packages: write
24+
25+
env:
26+
REGISTRY: ghcr.io
27+
GHCR_REPO: ghcr.io/${{ github.repository }}
28+
SKIP_ENV_VALIDATION: "true"
29+
TURBO_TELEMETRY_DISABLED: "1"
30+
31+
concurrency:
32+
group: ${{ github.workflow }}-${{ github.ref_name }}
33+
34+
jobs:
35+
resolve-tag:
36+
name: Resolve image tag
37+
if: github.repository == 'hatlabs/homarr'
38+
runs-on: ubuntu-latest
39+
outputs:
40+
tag: ${{ steps.pick.outputs.tag }}
41+
is_release: ${{ steps.pick.outputs.is_release }}
42+
steps:
43+
- id: pick
44+
run: |
45+
if [ "${{ github.event_name }}" = "push" ]; then
46+
tag="${GITHUB_REF#refs/tags/}"
47+
is_release=true
48+
elif [ -n "${{ inputs.tag }}" ]; then
49+
tag="${{ inputs.tag }}"
50+
is_release=true
51+
else
52+
tag="$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/-/g')"
53+
is_release=false
54+
fi
55+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
56+
echo "is_release=$is_release" >> "$GITHUB_OUTPUT"
57+
58+
build-amd64:
59+
name: Build amd64 image
60+
needs: resolve-tag
61+
runs-on: ubuntu-latest
62+
timeout-minutes: 30
63+
outputs:
64+
digest: ${{ steps.build.outputs.digest }}
65+
steps:
66+
- uses: actions/checkout@v6
67+
68+
- uses: docker/metadata-action@v6
69+
id: meta
70+
with:
71+
images: ${{ env.GHCR_REPO }}
72+
73+
- uses: docker/login-action@v4
74+
with:
75+
registry: ${{ env.REGISTRY }}
76+
username: ${{ github.actor }}
77+
password: ${{ secrets.GITHUB_TOKEN }}
78+
79+
- uses: docker/setup-buildx-action@v4
80+
81+
- id: build
82+
uses: docker/build-push-action@v7
83+
with:
84+
context: .
85+
network: host
86+
platforms: linux/amd64
87+
labels: ${{ steps.meta.outputs.labels }}
88+
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
89+
env:
90+
SKIP_ENV_VALIDATION: "true"
91+
92+
build-arm64:
93+
name: Build arm64 image
94+
needs: resolve-tag
95+
runs-on: ubuntu-24.04-arm
96+
timeout-minutes: 45
97+
outputs:
98+
digest: ${{ steps.build.outputs.digest }}
99+
steps:
100+
- uses: actions/checkout@v6
101+
102+
- uses: docker/metadata-action@v6
103+
id: meta
104+
with:
105+
images: ${{ env.GHCR_REPO }}
106+
107+
- uses: docker/login-action@v4
108+
with:
109+
registry: ${{ env.REGISTRY }}
110+
username: ${{ github.actor }}
111+
password: ${{ secrets.GITHUB_TOKEN }}
112+
113+
- uses: docker/setup-buildx-action@v4
114+
115+
- id: build
116+
uses: docker/build-push-action@v7
117+
with:
118+
context: .
119+
network: host
120+
platforms: linux/arm64
121+
labels: ${{ steps.meta.outputs.labels }}
122+
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
123+
env:
124+
SKIP_ENV_VALIDATION: "true"
125+
126+
publish:
127+
name: Publish multi-arch manifest
128+
needs: [resolve-tag, build-amd64, build-arm64]
129+
runs-on: ubuntu-latest
130+
timeout-minutes: 5
131+
steps:
132+
- uses: docker/login-action@v4
133+
with:
134+
registry: ${{ env.REGISTRY }}
135+
username: ${{ github.actor }}
136+
password: ${{ secrets.GITHUB_TOKEN }}
137+
138+
- name: Publish tag
139+
run: |
140+
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:${{ needs.resolve-tag.outputs.tag }} \
141+
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
142+
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
143+
144+
- name: Update latest-halos
145+
if: needs.resolve-tag.outputs.is_release == 'true'
146+
run: |
147+
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:latest-halos \
148+
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
149+
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}

.github/workflows/deployment-weekly-release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ permissions:
1717

1818
jobs:
1919
create-and-merge-pr:
20+
if: github.repository == 'homarr-labs/homarr'
2021
runs-on: ubuntu-latest
2122
timeout-minutes: 2
2223
steps:

FORK.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# `hatlabs/homarr` fork
2+
3+
This repository is a fork of [`homarr-labs/homarr`](https://github.com/homarr-labs/homarr)
4+
maintained by Hat Labs to ship the Homarr container that powers
5+
[HaLOS](https://halos.fi).
6+
7+
The fork carries a small set of patches that have not yet landed upstream.
8+
Patches are kept on top of clean upstream release tags so they can be carried
9+
forward by rebase rather than merge.
10+
11+
## Tag scheme
12+
13+
Fork release images are tagged as `v<upstream>-halos.<n>`:
14+
15+
- `<upstream>` — the upstream Homarr release the fork was rebased onto
16+
(e.g. `v1.59.3`).
17+
- `<n>` — fork iteration number (1-based) on top of that upstream release.
18+
19+
Example: `v1.59.3-halos.1` is the first fork release built on top of
20+
upstream `v1.59.3`.
21+
22+
When upstream cuts a new release the fork rebases its branch onto it and the
23+
counter resets:
24+
25+
```
26+
v1.59.3-halos.1 first fork release on v1.59.3
27+
v1.59.3-halos.2 second fork release (still on v1.59.3)
28+
v1.59.4-halos.1 first fork release after rebasing onto v1.59.4
29+
```
30+
31+
## Container images
32+
33+
Images are built by `.github/workflows/deployment-fork-image.yml` and pushed to
34+
`ghcr.io/hatlabs/homarr`:
35+
36+
- `ghcr.io/hatlabs/homarr:v<upstream>-halos.<n>` — the immutable build per
37+
fork release tag.
38+
- `ghcr.io/hatlabs/homarr:latest-halos` — moves with the most recent fork
39+
release.
40+
41+
The workflow is triggered by pushing a `v*-halos.*` tag, or manually via
42+
`workflow_dispatch` with a tag argument.
43+
44+
## Upstream workflow
45+
46+
The upstream `deployment-docker-image.yml` and `deployment-weekly-release.yml`
47+
workflows are guarded with `if: github.repository == 'homarr-labs/homarr'` so
48+
they no-op on the fork. This keeps the upstream files close to verbatim and
49+
minimizes rebase conflicts when pulling new upstream commits.
50+
51+
## Carried patches
52+
53+
Each fork branch should land via a normal hatlabs internal PR. The eventual
54+
upstream PR (where applicable) is prepared as a separate, narrower branch
55+
rebased onto the upstream merge target (`dev`).
56+
57+
### Commit hygiene for upstream-bound branches
58+
59+
Fork branches that are intended to also land upstream must keep
60+
**fork-only** changes in dedicated commits, separate from the
61+
upstream-relevant change. Fork-only material includes:
62+
63+
- `FORK.md`
64+
- `.github/workflows/deployment-fork-image.yml`
65+
- The `if: github.repository == 'homarr-labs/homarr'` guards added to
66+
upstream workflows
67+
- `docs/halos/` — fork-specific notes and learnings (organised under
68+
`learnings/` with YAML frontmatter; relevant when implementing or
69+
debugging fork-side patterns)
70+
- Anything else that only makes sense in the `hatlabs/homarr` context
71+
72+
This way the upstream PR can be prepared by cherry-picking the
73+
upstream-relevant commits only — typically a single contiguous range —
74+
without manual file-level surgery. Convention: one leading commit
75+
(`ci(fork): …`) carries every fork-only file; subsequent commits carry
76+
the upstream-bound changes.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
---
2+
title: Selectively rethrow tRPC errors in widgets inside an ErrorBoundary
3+
date: 2026-04-30
4+
module: packages/widgets/src/app
5+
problem_type: best_practice
6+
component: tooling
7+
severity: medium
8+
applies_when:
9+
- "Widget uses tRPC + react-query inside a React ErrorBoundary"
10+
- "One specific TRPCError code is a normal/expected condition, not a real failure"
11+
- "Other error codes from the same query should still surface as the widget's error UI"
12+
- "Replacing useSuspenseQuery with useQuery to gain access to query.error before render"
13+
tags:
14+
- trpc
15+
- react-query
16+
- error-boundary
17+
- use-suspense-query
18+
- use-query
19+
- widget-pattern
20+
- graceful-degradation
21+
---
22+
23+
# Selectively rethrow tRPC errors in widgets inside an ErrorBoundary
24+
25+
## Context
26+
27+
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.
28+
29+
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.
30+
31+
## Guidance
32+
33+
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`:
34+
35+
1. **Replace `useSuspenseQuery` with `useQuery`** and disable retries (`retry: false`) so the expected error doesn't trigger backoff churn.
36+
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.
37+
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.
38+
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.
39+
40+
The discriminator pattern:
41+
42+
```tsx
43+
if (query.error) {
44+
if (query.error.data?.code === "CONFLICT") {
45+
return <DegradedView tooltip={query.error.message} />;
46+
}
47+
throw query.error; // FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR → boundary
48+
}
49+
```
50+
51+
## Why This Matters
52+
53+
- **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.
54+
- **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.
55+
- **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.
56+
- **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.
57+
58+
## When to Apply
59+
60+
Apply this pattern when **all** of:
61+
62+
- The query runs inside a React `ErrorBoundary` (directly or via a parent widget framework).
63+
- At least one tRPC error code returned by the procedure represents *expected, valid* runtime state (config-driven, not a fault).
64+
- The component can render a meaningful degraded UI for that case.
65+
66+
Do **not** apply when:
67+
68+
- *Every* error from the procedure is genuinely a fault — `useSuspenseQuery` + boundary is simpler and correct.
69+
- 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.
70+
- You'd be tempted to catch *all* errors generically. That defeats the boundary. Discriminate on a specific known code.
71+
72+
Alternatives considered and why they're worse:
73+
74+
- **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.
75+
- **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.
76+
- **Try/catch around `useSuspenseQuery`.** Doesn't work — Suspense throws promises during render; you cannot catch the eventual error synchronously at the call site.
77+
78+
## Examples
79+
80+
Before — `packages/widgets/src/app/ping/ping-indicator.tsx`:
81+
82+
```tsx
83+
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
84+
{ id: appId },
85+
{ refetchOnMount: false, refetchOnWindowFocus: false },
86+
);
87+
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
88+
```
89+
90+
…wrapped at the call site in `packages/widgets/src/app/component.tsx` with `<Suspense fallback={<PingDot icon={IconLoader} … />}>`.
91+
92+
After:
93+
94+
```tsx
95+
const query = clientApi.widget.app.ping.useQuery(
96+
{ id: appId },
97+
{
98+
refetchOnMount: false,
99+
refetchOnWindowFocus: false,
100+
retry: false,
101+
},
102+
);
103+
104+
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(
105+
query.data ?? null,
106+
);
107+
108+
useEffect(() => {
109+
if (query.data) setPingResult(query.data);
110+
}, [query.data]);
111+
112+
clientApi.widget.app.updatedPing.useSubscription(
113+
{ id: appId },
114+
{ onData(data) { setPingResult(data); } },
115+
);
116+
117+
// Apps without a server-pingable URL (e.g. path-only href without an explicit
118+
// pingUrl) yield a CONFLICT. Render an indeterminate dot for that case so the
119+
// card stays usable. Other tRPC errors (FORBIDDEN, NOT_FOUND) still bubble to
120+
// the widget error boundary as before.
121+
if (query.error) {
122+
if (query.error.data?.code === "CONFLICT") {
123+
return <PingDot icon={IconLoader} color="blue" tooltip={query.error.message} />;
124+
}
125+
throw query.error;
126+
}
127+
128+
if (!pingResult) {
129+
return <PingDot icon={IconLoader} color="blue" tooltip="Pinging…" />;
130+
}
131+
```
132+
133+
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.
134+
135+
A cleaner variant that avoids the one-render lag (see tradeoffs):
136+
137+
```tsx
138+
const query = clientApi.widget.app.ping.useQuery(/**/);
139+
const [override, setOverride] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(null);
140+
141+
clientApi.widget.app.updatedPing.useSubscription(
142+
{ id: appId },
143+
{ onData: setOverride },
144+
);
145+
146+
const pingResult = override ?? query.data ?? null;
147+
// no useEffect needed; render is a pure derivation
148+
```
149+
150+
## Tradeoffs
151+
152+
- **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.
153+
- **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.
154+
- **`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.

0 commit comments

Comments
 (0)