
Widgetkit Code Review
- 103 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
widgetkit-code-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- widgetkit-code-review
- AI & Agent Building
- AI-coding skill
Widgetkit Code Review by the numbers
- 103 all-time installs (skills.sh)
- Ranked #4,276 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill widgetkit-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 103 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
WidgetKit Code Review
Quick Reference
| Issue Type | Reference |
|---|---|
| TimelineProvider, entries, reload policies | references/timeline.md |
| Widget families, containerBackground, deep linking | references/views.md |
| AppIntentConfiguration, EntityQuery, @Parameter | references/intents.md |
| Refresh budget, memory limits, caching | references/performance.md |
Review Checklist
- [ ]
placeholder(in:)returns immediately without async work - [ ] Timeline entries spaced at least 5 minutes apart
- [ ]
getSnapshotcheckscontext.isPreviewfor gallery previews - [ ]
containerBackground(for:)used for iOS 17+ compatibility - [ ]
widgetURLused for systemSmall (not Link) - [ ] No Button views (use Link or widgetURL)
- [ ] No AsyncImage or UIViewRepresentable in widget views
- [ ] Images downsampled to widget display size (~30MB limit)
- [ ] App Groups configured for data sharing between app and widget
- [ ] EntityQuery implements
defaultResult()for non-optional parameters - [ ] New intent parameters handle nil for existing widgets after updates
- [ ]
reloadTimelinescalled strategically (not on every data change)
When to Load References
- TimelineProvider implementation or refresh issues -> timeline.md
- Widget sizes, Lock Screen, containerBackground -> views.md
- Configurable widgets, AppIntent migration -> intents.md
- Memory issues, caching, budget management -> performance.md
Review Questions
1. Does the widget provide fallback entries for when system delays refresh? 2. Are Lock Screen families (accessoryCircular/Rectangular/Inline) handled appropriately? 3. Would migrating from IntentConfiguration break existing user widgets? 4. Is timeline populated with future entries or does it rely on frequent refreshes? 5. Is data cached via App Groups for widget access?
Hard gates (before reporting)
Complete in order for each finding you intend to report. Do not advance until the pass condition is satisfied.
1. Location artifact — The finding includes [FILE:LINE] (or a line range) copied from the current file contents; the path resolves in this repo. 2. Scope read — You read the full surrounding implementation: the TimelineProvider (including placeholder, getSnapshot, and getTimeline when relevant), the @main Widget / widget bundle, or the configurable widget’s AppIntentConfiguration / intent types—not only a diff hunk or snippet. 3. Platform or system claim (only if the finding depends on refresh budget, ~30MB memory guidance, Lock Screen accessory families, iOS 17+ containerBackground, App Groups data sharing, or migration from IntentConfiguration to AppIntentConfiguration) — You name one concrete artifact you inspected (for example .entitlements / App Group id in project, WidgetFamily handling in source, IPHONEOS_DEPLOYMENT_TARGET, or the exact reference subsection you used) or you drop or downgrade the finding to an open question. 4. Protocol — Pre-report steps in review-verification-protocol are satisfied for this item (no finding if they are not).
Use the issue format [FILE:LINE] ISSUE_TITLE for each reported finding. Hard gate 4 is the full pre-report checklist for this skill’s review type.
Configurable Widgets
Configuration Approaches
| Approach | iOS | Status |
|---|---|---|
StaticConfiguration | 14+ | Non-configurable widgets |
IntentConfiguration | 14+ | Legacy SiriKit intents |
AppIntentConfiguration | 17+ | Modern App Intents |
Migration warning: Changing from IntentConfiguration to AppIntentConfiguration can cause existing user widgets to disappear or freeze.
AppIntentTimelineProvider
struct ConfigurableProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> MyEntry { .placeholder } // Sync, instant
func snapshot(for config: MyIntent, in context: Context) async -> MyEntry {
MyEntry(date: .now, item: config.selectedItem)
}
func timeline(for config: MyIntent, in context: Context) async -> Timeline<MyEntry> {
let entry = MyEntry(date: .now, item: config.selectedItem)
return Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(900)))
}
}Widget Configuration
struct MyWidgetIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Configure Widget"
@Parameter(title: "Name", default: "Default") var name: String
@Parameter(title: "Style") var style: DisplayStyle // AppEnum
@Parameter(title: "Item") var selectedItem: ItemEntity? // AppEntity
}
struct MyWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "com.app.widget", intent: MyWidgetIntent.self,
provider: ConfigurableProvider()) { entry in
MyWidgetView(entry: entry)
}
}
}Dynamic Options
EntityStringQuery for Custom Types
struct ItemEntity: AppEntity {
static var defaultQuery = ItemQuery()
var id: String
var name: String
var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") }
}
struct ItemQuery: EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [ItemEntity] { /* fetch by IDs */ }
func entities(matching string: String) async throws -> [ItemEntity] { /* search */ }
func suggestedEntities() async throws -> [ItemEntity] { /* default list */ }
func defaultResult() async -> ItemEntity? { /* REQUIRED for non-optional params */ }
}DynamicOptionsProvider for Simple Types
@Parameter(title: "Hour", optionsProvider: HourOptionsProvider()) var hour: Int
struct HourOptionsProvider: DynamicOptionsProvider {
func results() async throws -> [Int] { Array(0..<24) }
func defaultResult() async -> Int? { 12 }
}Critical Anti-Patterns
Missing defaultResult()
// BAD: Widget shows "Select" instead of value
struct ItemQuery: EntityStringQuery { /* no defaultResult() */ }
// GOOD: Always implement for non-optional entity parameters
func defaultResult() async -> ItemEntity? { items.first }Ignoring Nil After App Updates
// BAD: Parameters added in updates are nil for existing widgets
let name = config.newParameter.name // Crash!
// GOOD: Handle optional parameters
let name = config.newParameter?.name ?? "Default"Heavy Work in Placeholder
// BAD: Blocks UI
func placeholder(in context: Context) -> Entry { Entry(data: fetchSync()) }
// GOOD: Return static data instantly
func placeholder(in context: Context) -> Entry { .placeholder }Breaking Migration
// BAD: Same kind causes widget disappearance
AppIntentConfiguration(kind: "widget", ...) // Was IntentConfiguration
// GOOD: Use new kind for new configuration type
AppIntentConfiguration(kind: "widget.v2", ...)Review Questions
1. Does EntityQuery implement `defaultResult()`? Missing causes "Select" UI instead of default. 2. Are new parameters optional-safe? Parameters added in updates are nil for existing widgets. 3. Is placeholder instant? Must be synchronous with static data only. 4. Does migration use new kind? Same kind string breaks existing widgets. 5. Is configuration stored in timeline entry? Entry must hold intent for view access. 6. Are AppEntity types Codable? Required for WidgetKit to persist configuration.
Widget Performance
Budget System
Widgets operate under strict refresh budgets to conserve battery:
- Daily budget: 40-70 refreshes for frequently viewed widgets
- Refresh interval: Every 15-60 minutes in production
- Debug mode: No limits during development
Timeline Policies
Timeline(entries: entries, policy: .atEnd) // Refresh when timeline exhausted
Timeline(entries: entries, policy: .after(date)) // Refresh after specific date
Timeline(entries: entries, policy: .never) // Manual refresh via reloadTimelines()Populate timelines with as many future entries as possible. Keep entries at least 5 minutes apart.
Memory Limits
Widgets are constrained to approximately 30MB - this applies collectively across all timeline entries.
// BAD: Loading full-resolution images
let image = UIImage(contentsOfFile: path)
// GOOD: Downsample to widget display size
func downsample(imageAt url: URL, to size: CGSize, scale: CGFloat) -> UIImage? {
let options = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, options) else { return nil }
let maxDim = max(size.width, size.height) * scale
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxDim
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { return nil }
return UIImage(cgImage: cg)
}Data Fetching
Network calls must complete within timeline generation. Never call APIs in getSnapshot():
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
Task {
guard let data = try? await fetchData() else {
completion(Timeline(entries: [Entry(date: Date(), data: cachedData)], policy: .after(Date().addingTimeInterval(900))))
return
}
completion(Timeline(entries: [Entry(date: Date(), data: data)], policy: .after(Date().addingTimeInterval(3600))))
}
}
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
completion(context.isPreview ? .sample : Entry(date: Date(), data: cachedData ?? .sample))
}For background downloads, use onBackgroundURLSessionEvents modifier on the widget configuration.
Caching Strategies
App Groups for Shared Data
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.widgets")
// Main app: save and notify widget
func saveWidgetData(_ data: WidgetData) {
if let encoded = try? JSONEncoder().encode(data) {
sharedDefaults?.set(encoded, forKey: "widgetData")
WidgetCenter.shared.reloadAllTimelines()
}
}
// Widget: read cached data
func loadWidgetData() -> WidgetData? {
guard let data = sharedDefaults?.data(forKey: "widgetData") else { return nil }
return try? JSONDecoder().decode(WidgetData.self, from: data)
}Both app and widget extension must have the same App Group in Signing & Capabilities.
Critical Anti-Patterns
AsyncImage Not Supported
// BAD: Widgets render synchronously
AsyncImage(url: imageURL)
// GOOD: Pre-fetch in timeline provider
Image(uiImage: cachedImage)Excessive Reloads
// BAD: Burns budget quickly
WidgetCenter.shared.reloadAllTimelines()
// GOOD: Reload specific widget strategically
WidgetCenter.shared.reloadTimelines(ofKind: "specificWidget")UIKit Components
// BAD: UIViewRepresentable not supported
MapViewRepresentable()
// GOOD: Use MKMapSnapshotter for map images
Image(uiImage: mapSnapshot)Keychain Access
Keychain can fail with errSecInteractionNotAllowed after extended periods. Use App Groups instead.
Sparse Timelines
// BAD: Forces frequent refreshes
Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(60)))
// GOOD: Pre-computed entries
let entries = (0..<24).map { Entry(date: Date().addingTimeInterval(Double($0) * 3600), data: data) }
Timeline(entries: entries, policy: .atEnd)Review Questions
1. Does the widget downsample images to display size, or load full-resolution assets? 2. Are timeline entries pre-computed for future dates to minimize refresh frequency? 3. Does getSnapshot() avoid network calls and use cached/sample data? 4. Is App Groups configured correctly for both app and widget extension targets? 5. Are reloadTimelines() calls strategic, or does every data update trigger a reload? 6. Does the widget view avoid AsyncImage and other async loading patterns?
Timeline Management
Core Concepts
WidgetKit renders widgets as static snapshots at predetermined times. The system controls refresh timing to optimize battery life, allowing 40-70 refreshes per day (every 15-60 minutes). Timeline entries should be at least 5 minutes apart. The system may delay refreshes significantly beyond requested times.
TimelineProvider Protocol
Three required methods with distinct purposes:
| Method | Sync/Async | Purpose |
|---|---|---|
placeholder(in:) | Synchronous | Redacted loading state; return immediately |
getSnapshot(in:completion:) | Async | Widget gallery preview; check context.isPreview |
getTimeline(in:completion:) | Async | Primary content; returns entries array + reload policy |
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> Entry {
Entry(date: .now, data: .placeholder) // Must be instant
}
func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {
completion(Entry(date: .now, data: context.isPreview ? .sample : .current))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let entries = (0..<12).map { hour in
Entry(date: Calendar.current.date(byAdding: .hour, value: hour, to: .now)!, data: .forHour(hour))
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}TimelineEntry
Requires only date property. Add custom properties for widget data:
struct MyEntry: TimelineEntry {
let date: Date // Required: when to display
let relevance: TimelineEntryRelevance? // Optional: Smart Stack ranking
let title: String // Custom data
}Reload Policies
| Policy | Behavior | Use Case |
|---|---|---|
.atEnd | Request new timeline after last entry expires | Regularly changing content |
.after(Date) | Wait until specified date | Known future update time |
.never | No auto-refresh; requires reloadTimelines call | App-driven updates only |
All policies are suggestions. System decides actual timing based on budget and battery.
App-Driven Reloads
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget") // Specific widget
WidgetCenter.shared.reloadAllTimelines() // All widgetsLimitations: Not immediate; may only update when app backgrounds; subject to daily budget.
Critical Anti-Patterns
// BAD: Entries too close together
for minute in 0..<60 {
let date = Calendar.current.date(byAdding: .minute, value: minute, to: now)!
entries.append(Entry(date: date))
}
// GOOD: Reasonable intervals (5+ minutes minimum)
for hour in 0..<24 {
let date = Calendar.current.date(byAdding: .hour, value: hour, to: now)!
entries.append(Entry(date: date))
}// BAD: Heavy work in synchronous placeholder
func placeholder(in context: Context) -> Entry {
let data = fetchDataSync() // Blocks UI, may timeout
return Entry(date: .now, data: data)
}// BAD: Ignoring isPreview
func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {
fetchRealData { completion(Entry(date: .now, data: $0)) } // Slow for gallery
}
// GOOD: Sample data for previews, real data otherwise
func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {
if context.isPreview {
completion(Entry(date: .now, data: .sample))
} else {
completion(Entry(date: .now, data: .current))
}
}// BAD: Expecting exact refresh timing
Timeline(entries: entries, policy: .after(exactDeadline)) // May refresh hours late
// GOOD: Include fallback entries past critical timesReview Questions
1. Does `placeholder(in:)` return immediately without async work? 2. Are timeline entries spaced at least 5 minutes apart? 3. Does `getSnapshot` check `context.isPreview` for gallery previews? 4. Is the reload policy appropriate? Static: .never; Dynamic: .atEnd/.after 5. Are there fallback entries past critical times? System may delay refreshes. 6. Is `reloadTimelines` called only when necessary? Each call consumes budget.
Widget Views
Widget Families
Home Screen: systemSmall, systemMedium, systemLarge, systemExtraLarge (iPad only)
Lock Screen (iOS 16+): accessoryCircular, accessoryRectangular, accessoryInline
.supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular])View Composition
Use @Environment(\.widgetFamily) for adaptive layouts:
@Environment(\.widgetFamily) var widgetFamily
var body: some View {
switch widgetFamily {
case .systemSmall: CompactView()
case .accessoryCircular: CircularWidgetView()
case .accessoryInline: Text(entry.summary)
default: DetailedView()
}
}- Use
@Environment(\.widgetRenderingMode)to detect Lock Screen vibrant mode AccessoryWidgetBackground()works foraccessoryCircular/accessoryRectangularonly- Use
ViewThatFitsfor content that may truncate
containerBackground
Required for iOS 17+. Widgets show error without this modifier.
Text("Content")
.containerBackground(for: .widget) { Color.blue }Backwards compatibility:
extension View {
func widgetBackground(_ bg: some View) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
return containerBackground(for: .widget) { bg }
} else { return background(bg) }
}
}Configuration modifiers:
.containerBackgroundRemovable(false)- Prevent removal in StandBy.contentMarginsDisabled()- Opt out of automatic margins
Deep Linking
| Size | Method | Notes |
|---|---|---|
systemSmall | widgetURL() | Entire widget is one tap target |
systemMedium/Large | Link or widgetURL() | Multiple tappable regions |
// Small widgets: entire widget taps
.widgetURL(URL(string: "myapp://item/\(entry.id)")!)
// Medium/Large: multiple targets
Link(destination: URL(string: "myapp://section1")!) { Text("Section 1") }Handle in app with .onOpenURL { url in handleDeepLink(url) }
Critical Anti-Patterns
| Issue | Problem |
|---|---|
Missing containerBackground | iOS 17 shows error instead of widget |
Link in systemSmall | Silently fails, only widgetURL works |
Button in widgets | Never works, use Link or widgetURL |
| Same view for all families | Content truncated or wasted space |
AccessoryWidgetBackground in accessoryInline | Renders empty view |
No URL validation in onOpenURL | Security risk from malformed deep links |
Review Questions
1. Does the widget use containerBackground(for:) for iOS 17+ compatibility? 2. Are Lock Screen families handled with appropriate compact layouts? 3. Is widgetURL used for systemSmall instead of Link? 4. Does the code avoid Button views (never work in widgets)? 5. Is AccessoryWidgetBackground excluded from accessoryInline contexts? 6. Are deep link URLs validated before navigation?