Skip to content

fix(ios): include the resolved style in VoltraElement identity - #274

Open
rlods wants to merge 2 commits into
callstackincubator:mainfrom
rlods:fix/ios-element-identity-stylesheet
Open

fix(ios): include the resolved style in VoltraElement identity#274
rlods wants to merge 2 commits into
callstackincubator:mainfrom
rlods:fix/ios-element-identity-stylesheet

Conversation

@rlods

@rlods rlods commented Sep 10, 2026

Copy link
Copy Markdown

Summary

On iOS, a payload that only changes a deduplicated style leaves the previous style on screen.

renderWidgetToString hoists every style object into a shared stylesheet (s) and leaves the element referencing it by index. VoltraElement is Hashable, and its == compares type, id, children and _props — where _props holds only that index, not the style it points at. Two payloads whose element serializes identically but whose stylesheet entry differs therefore compare equal.

VoltraNode is an Equatable View, so SwiftUI takes its fast path, skips re-evaluating the node, and keeps whatever it rendered last time.

Reproduction

Schedule a two-entry widget timeline in which a Text keeps its content and only its colour changes. The second Text is the control: its content does change, which proves the timeline advanced.

const Probe = ({ color, label }) => (
  <Voltra.VStack style={{ flex: 1, padding: 12, backgroundColor: '#FFFFFF' }}>
    <Voltra.Text style={{ fontSize: 32, color }}>42</Voltra.Text>
    <Voltra.Text style={{ fontSize: 14, color: '#282830' }}>{label}</Voltra.Text>
  </Voltra.VStack>
)

const now = new Date()

scheduleWidget('probe', [
  { date: now, variants: { systemSmall: <Probe color="#ff6d39" label="A orange" /> } },
  {
    date: new Date(now.getTime() + 60_000),
    variants: { systemSmall: <Probe color="#28283099" label="B grey" /> },
  },
])

Both entries serialize the number to the exact same node, {"t":0,"c":"42","p":{"s":0}}, and differ only inside the stylesheet:

// entry A
{"t":11,"c":[{"t":0,"c":"42","p":{"s":0}},{"t":0,"c":"A orange","p":{"s":1}}],"p":{"s":2}}
"s":[{"fs":32,"c":"#ff6d39"},   {"fs":14,"c":"#282830"}, {"fl":1,"pad":12,"bg":"#FFFFFF"}]

// entry B
{"t":11,"c":[{"t":0,"c":"42","p":{"s":0}},{"t":0,"c":"B grey","p":{"s":1}}],"p":{"s":2}}
"s":[{"fs":32,"c":"#28283099"}, {"fs":14,"c":"#282830"}, {"fl":1,"pad":12,"bg":"#FFFFFF"}]

After a minute the label flips to B grey, so the entry did change — but the 42 stays orange. Expected: it turns grey.

This is not limited to the toy case. It shows up wherever a style is deduplicated and only its contents change: a status widget that recolors an unchanged counter at a date boundary picks up the new background and the new caption, while the counter keeps its old colour, because the surrounding elements changed and it did not.

Fix

VoltraElement identity now includes the resolved style — the existing computed property that follows the stylesheet index — in both == and hash(into:). Nested elements are covered by recursion through children.

This makes equality strictly finer, so nodes that used to be skipped are re-evaluated. Nothing changes for payloads that ship inline styles, or that never reuse an index across renders.

Testing

  • swiftformat --lint ios: clean (0/163 files require formatting).
  • swift test: 146 tests pass.
  • shared/ + ui/ type-check against the iOS 17 simulator SDK.
  • Verified on device with the timeline above: orange 42 before the fix, grey 42 after.

No unit test in this PR: VoltraElement.swift and VoltraNode.swift are excluded from the VoltraSharedCore SPM target (VoltraNode conforms to View and dispatches into the ui layer), so no target in Package.swift can construct a VoltraElement. Happy to add one if you'd like the JSON→AST types split into a testable target.

Checklist

  • Tests pass (npm test)
  • Linting passes (npm run lint:libOnly)
  • Formatting is correct (npm run format:check)

🤖 Generated with Claude Code

rlods and others added 2 commits September 11, 2026 01:06
`_props` carries only the stylesheet index of a deduplicated style, so two
payloads pointing at different styles compared equal and SwiftUI's Equatable
fast path skipped the re-render, leaving the old style on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rlods
rlods marked this pull request as ready for review September 10, 2026 23:10
@rlods

rlods commented Sep 10, 2026

Copy link
Copy Markdown
Author

@V3RON 👋 here is an other fix

I applied it as a patch in my project and I confirm it resolves the described issue

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