Skip to content

docs(adr): server-driven Dynamic Widgets - #266

Merged
V3RON merged 6 commits into
mainfrom
claude/dynamic-server-widgets-plan-p8rlok
Sep 9, 2026
Merged

docs(adr): server-driven Dynamic Widgets#266
V3RON merged 6 commits into
mainfrom
claude/dynamic-server-widgets-plan-p8rlok

Conversation

@V3RON

@V3RON V3RON commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What is this?

ADR 0002, the plan for server-driven Dynamic Widgets (#176). Today a widget that wants fresh data without the app running has to use serverUpdate on a payload-driven widget, which means the server must run Voltra's JSX renderer. This ADR lets a Dynamic Widget fetch a plain JSON object from any HTTP endpoint and render it on device, so a PHP, C#, or Go backend can power a widget without Voltra on the server.

Documentation only. No code changes and no changeset.

How does it work?

  • entry decides how a widget renders, serverUpdate decides where data comes from. A widget with both is server-driven and Dynamic. No new config key.
  • The response contract for Dynamic Widgets is a JSON object, used verbatim as props. Payload widgets keep their existing contract.
  • serverUpdate in app.json becomes the default layer of a runtime settings store. The app can override URL, interval, method, query, headers, body, and enabled per widget or globally through setWidgetServerUpdate. The credential APIs become deprecated wrappers over the same store.
  • One resolver reads the four layers in a fixed order; both engines use it. Payload fetchers switch to it in the same change, which gives them runtime URLs and non-GET requests too.
  • Fetched props are trial-rendered before they are committed, so the screen never goes blank because of the network. The failure table covers network, HTTP, auth, parse, and render errors per platform.
  • Every per-widget store is keyed by a WidgetScope type and the request and env reserve instance names, so the later per-instance work (feat(android): configure Dynamic Widgets per placed instance #218) extends this design instead of rewriting its storage.

Platform behaviour was checked against Apple's WidgetKit documentation, Android's WorkManager and Glance documentation, the Glance source, and the platform HTTP stacks (GET with a body is dropped by URLSession and silently turned into POST by Android's HttpURLConnection).

Why is this useful?

Any backend can drive a widget, the request can carry per-user input, and the app can change the endpoint or take a widget over at runtime. Everything is additive. The one behaviour change is that entry plus serverUpdate, which is silently ignored today, starts fetching props.

The status is "Accepted — not yet implemented", which per the ADR README makes the document the specification for whoever implements it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ec7iFMH6yjH1W4zTEHyzGN


Generated by Claude Code

Adds ADR 0002 planning a device-fetched JSON props source for Dynamic
Widgets so any backend can drive a widget without running Voltra's
renderer. Covers config, request/response contract, engine selection,
scheduling, and failure handling per platform. Tracks #176.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec7iFMH6yjH1W4zTEHyzGN
Server-driven Dynamic Widgets reuse the existing serverUpdate key and are
selected by the presence of entry. Adds a layered widget server request
store shared by both engines, an explicit request API (method, query,
headers, body), and the GET-with-body rule verified against platform
HTTP stacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec7iFMH6yjH1W4zTEHyzGN
app.json serverUpdate becomes the default layer of a runtime settings
store. The app can override url, interval, method, query, headers, body,
and enabled per widget or globally. Drops family for Dynamic Widgets,
deprecates the credential APIs, records the agreed decisions on interval
floors, cache headers, trial render scope, and deferred events.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec7iFMH6yjH1W4zTEHyzGN
Key every per-widget store by a WidgetScope type, resolve settings per
scope, reserve instance names in the request and env, and record the
ordering against the open per-instance configuration work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec7iFMH6yjH1W4zTEHyzGN
Props writes never pause server fetching; only enabled: false does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec7iFMH6yjH1W4zTEHyzGN
@V3RON
V3RON force-pushed the claude/dynamic-server-widgets-plan-p8rlok branch from fae7528 to 6970018 Compare September 5, 2026 19:04
@V3RON
V3RON marked this pull request as ready for review September 8, 2026 17:25
@V3RON
V3RON merged commit eb251e4 into main Sep 9, 2026
14 checks passed
@V3RON
V3RON deleted the claude/dynamic-server-widgets-plan-p8rlok branch September 9, 2026 02:20
V3RON added a commit that referenced this pull request Sep 9, 2026
Implements ADR 0002. Stacked on #266, which adds the ADR; review and
merge that first, and this retargets to `main`. Closes #176.

## What is this?

A widget that wants fresh data without the app running has had one
option: `serverUpdate` on a payload-driven widget, where the server runs
Voltra's JSX renderer and returns a full UI payload. That rules out any
backend not written in Node, the fetch is GET-only, and the URL is
frozen at build time with no way to tell the server which account or
range the widget wants.

Giving a widget both `entry` and `serverUpdate` now makes it a
server-driven Dynamic Widget: the device fetches a plain JSON object
from the configured URL and passes it to the bundled JS as props.
Rendering stays on the device and the server never sees a component
tree, so a hand-written PHP script is enough.

`serverUpdate` in app.json also becomes a set of defaults rather than
the final word. `setWidgetServerUpdate` lets an app change the URL,
interval, method, query, headers and body at runtime — per widget or for
all of them, for both render engines — and turn fetching off entirely to
drive a widget itself.

## How does it work?

`entry` decides how a widget renders and `serverUpdate` decides where
its data comes from, so the two keys together select one of four
providers on iOS and receivers on Android. That choice is made once,
when the native project is generated; no runtime code asks whether a
widget is server-driven.

Both engines read their settings through one resolver per platform,
composed of four layers stacked in a fixed order — app.json config, the
deprecated credentials, global runtime settings, per-widget runtime
settings. `headers` and `query` merge per key; everything else takes the
value from the most specific layer that sets it. That rule lives in the
resolver and nowhere else, and the same code builds every request, which
is what gives payload widgets runtime URLs and non-GET methods too.
Everything per widget is keyed by a `WidgetScope` rather than a bare id,
so per-instance fetches can be added later without changing callers.

Every fetch is fetch, parse, trial-render, commit. Props are rendered
once off screen before they are committed, so a response the widget
cannot draw leaves the last good one on screen rather than blanking the
widget. A body that is not a JSON object is rejected, and one shaped
like a Voltra payload is rejected by name, since pointing a widget with
an `entry` at a payload endpoint is the mistake sharing the config key
invites. `ETag`, `Cache-Control: max-age` and `Retry-After` are
honoured, clamped to what each platform can schedule.

Widgets see the outcome on `env.serverUpdate` — `status`, `fetchedAt`,
`error`, `httpStatus` — so they can show "updated 3 min ago", dim when
the data is stale, or hide the freshness line while the app has taken
them over. That is the only change to the existing Dynamic render path:
an environment seam the plain engine leaves empty.

On Android the work runs in the app process through WorkManager and
never pushes `RemoteViews`; drawing stays in `VoltraClientGlanceWidget`.
On iOS the new timeline provider wraps the existing one, keeping
`placeholder` and `getSnapshot` off the network.

## Why is this useful?

Any backend can drive a widget now, in any language, by returning the
data it already has instead of learning Voltra's renderer. Per-tenant
deployments can ship a widget with no URL at all and supply one after
login. An app can point a widget at a different endpoint, add an auth
header, or take the widget over entirely without a rebuild.

Payload widgets get the same runtime settings, ETag revalidation and
`locale` parameter without any config change, and keep their existing
request and schedule when no runtime settings are set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants