Repository navigation
[ios][widgets] Fix Live Activity getInstances() filtering and start URL scoping - #48489
Conversation
… factories' activities getInstances() returned every live activity in the app regardless of which factory it was called on, and stamped the caller's name onto each wrapper. A later update() through such a wrapper overwrote an unrelated activity's content. It also returned activities already ended or dismissed by the system, which cannot be updated. Filter by the persisted content.state.name and keep only active/stale activities (stale ones are still visible and updatable). The guard moves from iOS 16.1 to 16.2 because Activity.content requires it; start() already requires 16.2, so no activity can exist below that. Fixes expo#48396 Co-Authored-By: Claude Fable 5 <[email protected]>
…deliver it to the banner
The URL passed to LiveActivityFactory.start(props, url) was stored in
WidgetsStorage under a key derived from the factory name: written only
when non-nil, never cleared, and shared by every activity started under
that name. A later start() without a URL silently inherited the previous
one. On top of that, .widgetURL() was only applied to the Dynamic Island
builder, so the Lock Screen banner never received the URL at all and
tapping it could not deep-link into the app.
Store the URL in LiveActivityAttributes instead. Attributes are fixed at
Activity.request and never rewritten, which is exactly the semantics of
a start() URL: scoped to one activity, unaffected by content updates,
nothing to clear. Apply .widgetURL(context.attributes.url) in both the
banner content closure and the Dynamic Island builder, and drop the now
unused WidgetsStorage plumbing (getLiveActivityUrl and the
__expo_widgets_live_activity_<name>_url key).
The new attribute is optional, so activities started by a previous
version (attributes decoded as {}) and push-to-start payloads without it
keep working; they simply carry no URL.
Fixes expo#48400
Fixes expo#48401
Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
|
Subscribed to pull request
Generated by CodeMention |
|
/review |
🤖 AI code reviewDecision: Approve with comments Overall PR risk: Medium. The change modifies existing iOS Live Activity behavior: it moves the deep link URL from app-group storage into 🟡 Warning (3)
This review is advisory — it never blocks a merge and never auto-approves. |
Layouts can already set their own deep link through the widgetURL modifier from @expo/ui, and a view hierarchy with more than one widgetURL modifier is undefined behavior per the modifier's own documentation. Applying .widgetURL(nil) unconditionally above the layout could therefore override a URL the layout sets. Branch on the attribute instead, in both the Lock Screen banner closure and the Dynamic Island builder. The island had the always-applied pattern before this PR too, so this also removes a pre-existing conflict with layouts that set widgetURL on island sections. Co-Authored-By: Claude Fable 5 <[email protected]>
|
Replying to the AI review point by point: 1. Push-to-start payload can now set the Live Activity tap URL — acknowledged, leaving as is. 2. Live Activities started before the app update lose their deep link — acknowledged (it's also called out in the PR description) and deliberately accepted. One caution about the suggested remediation: the bare fallback 3. Lock Screen banner always applies widgetURL, so it can override a layout widgetURL — good catch, fixed in 028ab24. |
…RL scoping (expo#48489) # Why Fixes three related bugs in the expo-widgets Live Activity plumbing: - Closes expo#48396 — `LiveActivityFactory.getInstances()` returns every live activity in the app regardless of which factory it is called on, stamps the caller's name onto each wrapper, and also returns activities that have already ended or been dismissed. With two factories, calling `update()` through such a wrapper overwrites an unrelated activity's content. - Closes expo#48400 — the URL passed to `start(props, url)` is only applied to the Dynamic Island; the Lock Screen banner never receives it, so tapping the banner cannot deep-link into the app. - Closes expo#48401 — the URL is stored in `WidgetsStorage` under a key derived from the factory name: written only when non-nil, never cleared, and shared by every activity started under that name. A later `start()` without a URL silently inherits the previous one. # How **`getInstances()`** now filters `Activity<LiveActivityAttributes>.activities` by the persisted `content.state.name` and keeps only `.active`/`.stale` activities — a stale activity is still visible and updatable, while ended/dismissed ones are not. The availability guard moves from iOS 16.1 to 16.2 because `Activity.content` requires it; `start()` already required 16.2, so no activity created by this module can exist below that. **The start URL** moves from `WidgetsStorage` into `LiveActivityAttributes`. Attributes are fixed at `Activity.request` and never rewritten, which is exactly the semantics of a per-`start()` deep link: scoped to one activity, unaffected by content updates, nothing to clear. `.widgetURL(context.attributes.url)` is applied in both the Lock Screen banner closure and the Dynamic Island builder (previously only the island had it). The now-unused `getLiveActivityUrl` helper and `__expo_widgets_live_activity_<name>_url` storage key are removed. The new attribute is optional, so existing activities started by a previous version (attributes decode as `{}`) and push-to-start payloads that don't include it keep working; they simply carry no URL. Notes for reviewers — small deliberate behavior changes: - On iOS 16.1 exactly, `getInstances()` now throws `LiveActivitiesNotSupportedException` instead of returning `[]` (consistent with `start()` on that version; no activity can exist there anyway). - Ended-but-still-visible activities are no longer enumerable through `getInstances()`, matching its documented "currently active instances" contract. Exposing `id`/`activityState` on `NativeLiveActivity` so cleanup paths can enumerate them (the secondary ask in expo#48396) is left for a follow-up PR. - An activity started before an app update loses its deep link after the update (its attributes were encoded without a URL, and the storage key is no longer read). Transient by nature — Live Activities live at most ~12 h. # Test Plan - `et check-packages expo-widgets` passes (build, typecheck, lint, format). - To reproduce, use the repro app from the issues: https://github.com/MarianoFacundoArch/expo-widgets-getinstances-repro (scenarios 1–3 in its README, `[REPRO]`-prefixed logs in Metro): - Scenario 1: after `factoryA.start()`, `factoryA.getInstances().length == 1` and `factoryB.getInstances().length == 0`; `factoryB.getInstances()[0].update()` no longer overwrites activity A. - Scenario 2: with `factory.start(props, 'myapp://somewhere')`, tapping the Lock Screen banner now opens the app at `myapp://somewhere`. - Scenario 3: after `end('immediate')` followed by `start(props)` without a URL, the new activity carries no URL. # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). — no plugin/prebuild changes; iOS runtime code only. - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Jakub Grzywacz <[email protected]>
…RL scoping (#48489) Fixes three related bugs in the expo-widgets Live Activity plumbing: - Closes #48396 — `LiveActivityFactory.getInstances()` returns every live activity in the app regardless of which factory it is called on, stamps the caller's name onto each wrapper, and also returns activities that have already ended or been dismissed. With two factories, calling `update()` through such a wrapper overwrites an unrelated activity's content. - Closes #48400 — the URL passed to `start(props, url)` is only applied to the Dynamic Island; the Lock Screen banner never receives it, so tapping the banner cannot deep-link into the app. - Closes #48401 — the URL is stored in `WidgetsStorage` under a key derived from the factory name: written only when non-nil, never cleared, and shared by every activity started under that name. A later `start()` without a URL silently inherits the previous one. **`getInstances()`** now filters `Activity<LiveActivityAttributes>.activities` by the persisted `content.state.name` and keeps only `.active`/`.stale` activities — a stale activity is still visible and updatable, while ended/dismissed ones are not. The availability guard moves from iOS 16.1 to 16.2 because `Activity.content` requires it; `start()` already required 16.2, so no activity created by this module can exist below that. **The start URL** moves from `WidgetsStorage` into `LiveActivityAttributes`. Attributes are fixed at `Activity.request` and never rewritten, which is exactly the semantics of a per-`start()` deep link: scoped to one activity, unaffected by content updates, nothing to clear. `.widgetURL(context.attributes.url)` is applied in both the Lock Screen banner closure and the Dynamic Island builder (previously only the island had it). The now-unused `getLiveActivityUrl` helper and `__expo_widgets_live_activity_<name>_url` storage key are removed. The new attribute is optional, so existing activities started by a previous version (attributes decode as `{}`) and push-to-start payloads that don't include it keep working; they simply carry no URL. Notes for reviewers — small deliberate behavior changes: - On iOS 16.1 exactly, `getInstances()` now throws `LiveActivitiesNotSupportedException` instead of returning `[]` (consistent with `start()` on that version; no activity can exist there anyway). - Ended-but-still-visible activities are no longer enumerable through `getInstances()`, matching its documented "currently active instances" contract. Exposing `id`/`activityState` on `NativeLiveActivity` so cleanup paths can enumerate them (the secondary ask in #48396) is left for a follow-up PR. - An activity started before an app update loses its deep link after the update (its attributes were encoded without a URL, and the storage key is no longer read). Transient by nature — Live Activities live at most ~12 h. - `et check-packages expo-widgets` passes (build, typecheck, lint, format). - To reproduce, use the repro app from the issues: https://github.com/MarianoFacundoArch/expo-widgets-getinstances-repro (scenarios 1–3 in its README, `[REPRO]`-prefixed logs in Metro): - Scenario 1: after `factoryA.start()`, `factoryA.getInstances().length == 1` and `factoryB.getInstances().length == 0`; `factoryB.getInstances()[0].update()` no longer overwrites activity A. - Scenario 2: with `factory.start(props, 'myapp://somewhere')`, tapping the Lock Screen banner now opens the app at `myapp://somewhere`. - Scenario 3: after `end('immediate')` followed by `start(props)` without a URL, the new activity carries no URL. - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). — no plugin/prebuild changes; iOS runtime code only. - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Jakub Grzywacz <[email protected]>
## Why Ended Live Activities can still be visible on the Lock Screen, but `getInstances()` filters them out. Apps cannot retrieve them to dismiss them explicitly, as discussed in #51642. ## How Partially revert #48489: remove the state filter and keep the factory-name filter. Add `getState()` so apps can choose which activities to update or dismiss. Callers that only need active instances must now filter by state. Each state lookup scans ActivityKit's activities by ID, like `update()` and `end()`. ## Test Plan ## Screenshots No UI change. ## Checklist - [x] I added a changeset and followed [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
Why
Fixes three related bugs in the expo-widgets Live Activity plumbing:
LiveActivityFactory.getInstances()returns every live activity in the app regardless of which factory it is called on, stamps the caller's name onto each wrapper, and also returns activities that have already ended or been dismissed. With two factories, callingupdate()through such a wrapper overwrites an unrelated activity's content.start(props, url)is only applied to the Dynamic Island; the Lock Screen banner never receives it, so tapping the banner cannot deep-link into the app.WidgetsStorageunder a key derived from the factory name: written only when non-nil, never cleared, and shared by every activity started under that name. A laterstart()without a URL silently inherits the previous one.How
getInstances()now filtersActivity<LiveActivityAttributes>.activitiesby the persistedcontent.state.nameand keeps only.active/.staleactivities — a stale activity is still visible and updatable, while ended/dismissed ones are not. The availability guard moves from iOS 16.1 to 16.2 becauseActivity.contentrequires it;start()already required 16.2, so no activity created by this module can exist below that.The start URL moves from
WidgetsStorageintoLiveActivityAttributes. Attributes are fixed atActivity.requestand never rewritten, which is exactly the semantics of a per-start()deep link: scoped to one activity, unaffected by content updates, nothing to clear..widgetURL(context.attributes.url)is applied in both the Lock Screen banner closure and the Dynamic Island builder (previously only the island had it). The now-unusedgetLiveActivityUrlhelper and__expo_widgets_live_activity_<name>_urlstorage key are removed.The new attribute is optional, so existing activities started by a previous version (attributes decode as
{}) and push-to-start payloads that don't include it keep working; they simply carry no URL.Notes for reviewers — small deliberate behavior changes:
getInstances()now throwsLiveActivitiesNotSupportedExceptioninstead of returning[](consistent withstart()on that version; no activity can exist there anyway).getInstances(), matching its documented "currently active instances" contract. Exposingid/activityStateonNativeLiveActivityso cleanup paths can enumerate them (the secondary ask in [expo-widgets] LiveActivityFactory.getInstances() returns live activities belonging to other factories, relabelled with the caller's name #48396) is left for a follow-up PR.Test Plan
et check-packages expo-widgetspasses (build, typecheck, lint, format).[REPRO]-prefixed logs in Metro):factoryA.start(),factoryA.getInstances().length == 1andfactoryB.getInstances().length == 0;factoryB.getInstances()[0].update()no longer overwrites activity A.factory.start(props, 'myapp://somewhere'), tapping the Lock Screen banner now opens the app atmyapp://somewhere.end('immediate')followed bystart(props)without a URL, the new activity carries no URL.Checklist
changelog.mdentry and rebuilt the package sources according to this short guidenpx expo prebuild& EAS Build (eg: updated a module plugin). — no plugin/prebuild changes; iOS runtime code only.🤖 Generated with Claude Code