Skip to content

refactor(implementations): fetch iOS reference entries with contentful.swift [NT-3946] - #429

Draft
David Nalchevanidze (nalchevanidze) wants to merge 3 commits into
mainfrom
nt-3946-ios-contentful-swift
Draft

refactor(implementations): fetch iOS reference entries with contentful.swift [NT-3946]#429
David Nalchevanidze (nalchevanidze) wants to merge 3 commits into
mainfrom
nt-3946-ios-contentful-swift

Conversation

@nalchevanidze

@nalchevanidze David Nalchevanidze (nalchevanidze) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Why

The iOS reference app fetched Contentful entries with hand-rolled HTTP — a URL string, URLSession, JSONSerialization into untyped dictionaries, and its own includes link resolver with a depth-10 hop budget. Reference implementations are meant to consume the public SDK surface the way customers do, and the recommended path is Contentful's official CDA SDK. The Optimization SDK has shipped the adapter for it since NT-3808 (#393); no reference app used it.

Closes NT-3946.

What

Three separable commits, ordered so every commit is green:

Commit Scope
fix(swift): restore SwiftUI OptimizedEntry tap tracking [NT-3829] SDK bug fix
fix(swift): expose CTEntry dictionary and JSON initializers as public [NT-3946] SDK/Android parity
refactor(implementations): fetch iOS reference entries with contentful.swift [NT-3946] The ticket

1. SwiftUI tap tracking was broken

The tap observer added for NT-3829 attached its UITapGestureRecognizer to its superview, assuming .background() makes the observer a sibling of a content view. SwiftUI draws Text and friends into a shared display list rather than one UIView per view, and places the observer in a container of its own — so that container isn't reliably an ancestor of the tapped region, and the recognizer never saw the touch. The SwiftUI shell emitted no component_click events at all.

Now attached to the window (always an ancestor) and scoped to the observer's own frame, which .background() sizes to the tracked content. cancelsTouchesInView, delaysTouchesBegan, and delaysTouchesEnded are all off, so it never competes for a nested child's touches — NT-3829 is preserved by construction. Detaches on window change and deinit.

This was pre-existing on main, confirmed by stashing this branch's changes and reproducing identically at HEAD. It went unnoticed because NT-3829's regression test only asserts the nested Button's own action fires, so it passed either way — and the two tests that assert the click event aren't in CI, which runs only PreviewPanelTests for iOS.

2. CTEntry construction parity

CTEntry was constructible only from a Contentful.Entry; init(any:)/init(json:) were internal. Android already exposes all three shapes (from(CDAEntry), from(Map), from(String)), so this closes a gap rather than inventing API. The dictionary initializer takes [String: Any] to match Android's Map signature. empty became public because Swift rejects an internal declaration in a public default argument, where Kotlin permits it.

Note SDK APIs still accept entries as dictionaries, not CTEntry, so these initializers are for reading an entry — not for passing one back into resolution.

3. The migration

Both shells fetch with contentful.swift and hand the Contentful.Entry to the typed SDK entry points — OptimizedEntry(entry:) in SwiftUI, resolveOptimizedEntry(baseline:) in UIKit. App-owned CDA request construction, JSON parsing, and link resolution are deleted; link resolution and cycle handling come from LinkResolver plus the SDK's CTEntry encoding. isNestedContent collapses to entry.sys.contentTypeId. Requests stay single-locale via localizeResults(withLocaleCode:) with include(10). The preview panel's client is migrated too, mapping back down with CTEntry.toDictionary() for the dictionary-shaped PreviewContentfulClient.

Expanded child entries and locally built test entries only exist as dictionaries, so the nested renderers keep the dictionary initializers.

The mock transport shim

contentful.swift 5.5.15 can't express the mock server's /contentful/ path prefix (it builds /spaces/... from the host root, and host accepts only host[:port]), and it fetches /locales before its first entry request, which the mock doesn't serve.

Rather than change test infrastructure shared with Android and the web SDKs, MockContentfulTransport adapts both in-app. This is demo-only plumbing — a production app builds Contentful.Client(spaceId:accessToken:) against cdn.contentful.com and needs none of it. Its doc comment and the README both say so. The alternative — additive root-level CDA routes plus a /locales route in lib/mocks, reusable by NT-3947 — is still on the table if reviewers prefer it.

Validation

Check Result
SwiftUI full XCUITest suite 69/69
UIKit full XCUITest suite 69/69 (one hittability flake, passes in isolation)
pnpm ios:test 187/187 (was 182)
Both app targets build clean, no source warnings
format:check / git diff --check clean

SwiftUI went 67/69 → 69/69: the two tap-tracking failures were pre-existing.

Behaviour parity was checked specifically where the CDA migration could regress quietly — merge-tag resolution through rich text, all three nested-variant levels, and variant→baseline back-edges under CTEntry's ancestor-based cycle handling, which replaces the old depth-10 budget.

For reviewers

  • The commits are separable and may want to be separate PRs. If this is squash-merged, only the title's type/scope reaches the changelog, so the two fix(swift) changes would not get Swift changelog entries. Happy to split.
  • UIKit was validated before the tap fix. TapTrackingModifier is a SwiftUI ViewModifier reachable only via SwiftUI OptimizedEntry, and the UIKit shell renders through OptimizedEntryUIView — so it can't be affected. That's reasoning, not a test run; say the word and I'll re-run.
  • CI's iOS matrix is narrow. It runs only PreviewPanelTests, which is why a fully broken tap-tracking path survived. Widening it would be a cheap separate improvement.
  • documentation/guides/integrating-the-optimization-ios-sdk-in-{a-swiftui,a-uikit}-app.md already documented this integration, so they needed no edits.

🤖 Generated with Claude Code

The tap observer added for NT-3829 attached its UITapGestureRecognizer to
its `superview`, assuming `.background()` makes the observer a sibling of a
content *view*. SwiftUI draws Text and friends into a shared display list
rather than one UIView per view, and places the observer in a container of
its own, so that container is not reliably an ancestor of the region the
user taps and the recognizer never saw the touch. No `component_click`
event was emitted from the SwiftUI shell at all.

Attach to the window instead, which is always an ancestor, and scope each
tap to the observer's own frame — `.background()` sizes it to the tracked
content. `cancelsTouchesInView`, `delaysTouchesBegan`, and
`delaysTouchesEnded` are all off, so observing from the window still never
competes for touches a nested interactive child needs, preserving the
NT-3829 fix. Window-level recognizers need removing, so detach on window
change and deinit.

The nested-Button regression test added by NT-3829 only asserts the
Button's own action fires, so it passed either way; the two XCUITests that
assert the click event are not in CI, which runs only PreviewPanelTests
for iOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… [NT-3946]

`CTEntry` was constructible by consumers only from a `Contentful.Entry`;
`init(any:)` and `init(json:)` were internal, so a caller holding an entry
as a dictionary — an expanded child entry, a locally built one — could not
wrap it to read fields through `getField`/`hasField`.

Android already exposes all three shapes (`CTEntry.from(CDAEntry)`,
`from(Map)`, `from(String)`), so this closes an iOS/Android gap rather than
adding new API surface. The dictionary initializer takes `[String: Any]`
instead of the internal `Any` overload, matching Android's `Map` signature,
and stays fail-soft; `init(json:)` keeps throwing, which is the idiomatic
Swift equivalent of Android's fallback parameter.

`empty` becomes public because Swift rejects an internal declaration in a
public default argument, where Kotlin permits it — hence Android keeping
`EMPTY` internal.

Note that SDK APIs still accept entries as dictionaries, not `CTEntry`, so
these initializers are for reading an entry rather than passing one back
into resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l.swift [NT-3946]

The iOS reference app fetched Contentful entries with hand-rolled HTTP: a
URL string, URLSession, JSONSerialization into untyped dictionaries, and its
own `includes` link resolver with a depth-10 hop budget. Reference
implementations are meant to consume the public SDK surface the way
customers do, and the recommended path is Contentful's official CDA SDK —
which the Optimization SDK already ships an adapter for (NT-3808, #393)
without any reference app using it.

Both shells now fetch with contentful.swift and hand the resulting
`Contentful.Entry` to the SDK's typed entry points: `OptimizedEntry(entry:)`
in SwiftUI and `resolveOptimizedEntry(baseline:)` in UIKit. App-owned CDA
request construction, JSON parsing, and link resolution are deleted;
link resolution and cycle handling now come from contentful.swift's
LinkResolver plus the SDK's `CTEntry` encoding. `isNestedContent` collapses
to `entry.sys.contentTypeId`. Requests stay single-locale via
`localizeResults(withLocaleCode:)` with `include(10)`, per the CDA entry
contract. The preview panel's client is migrated too, mapping results back
down with `CTEntry.toDictionary()` for the dictionary-shaped
`PreviewContentfulClient` protocol.

Expanded child entries and locally built test entries only exist as
dictionaries, so the nested renderers and the dictionary initializers stay —
`CTEntry` cannot be constructed from a dictionary at every SDK input
boundary, since those accept dictionaries rather than `CTEntry`.

contentful.swift 5.5.15 cannot express the mock server's `/contentful/` path
prefix (it builds `/spaces/...` from the host root) and fetches `/locales`
before its first entry request, which the mock does not serve. Rather than
change test infrastructure shared with Android and the web SDKs,
`MockContentfulTransport` adapts both locally. It is demo-only plumbing: a
production app builds `Contentful.Client(spaceId:accessToken:)` against
cdn.contentful.com and needs none of it, which its doc comment and the
README both state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant