Skip to content

Render ForEach and keyed dynamic collections correctly#54

Merged
phranck merged 18 commits into
mainfrom
issue/12-keyed-dynamic-collections
Jul 21, 2026
Merged

Render ForEach and keyed dynamic collections correctly#54
phranck merged 18 commits into
mainfrom
issue/12-keyed-dynamic-collections

Conversation

@phranck

@phranck phranck commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Closes #12.

Problem

ForEach rendered no content outside the special List path because ForEach.body was Never, ViewBuilder.buildArray only handled Swift for statements, and the renderer silently returned an empty buffer for a standalone ForEach expression. List and Table row extraction used positional identity, so State, lifecycle, focus, and selection did not follow elements across insert, delete, and reorder.

Changes

  • Add a keyed dynamic-child model (KeyedCollectionSnapshot) shared by ForEach, List row extraction, and Table row identity. Each element gets a stable identity derived from its explicit ID plus an occurrence disambiguator for duplicate-ID diagnostics.
  • Give ForEach a real private core (_ForEachCore) that conforms to Renderable, ChildInfoProvider, and ChildViewProvider, and routes through RenderContext.withKeyedChildIdentity.
  • Propagate dynamic children through TupleView, ConditionalView, and ViewArray by recursing into ChildInfoProvider / ChildViewProvider so nested builders and conditional ForEach flatten correctly.
  • Render List rows and Table data through keyed identities via extractListRows and KeyedCollectionSnapshot so runtime records follow row IDs.
  • Preserve focus and selection by item ID across insert, delete, and reorder through ItemListHandler keyed itemIDs.
  • Emit deterministic per-runtime duplicate-ID diagnostics via RuntimeDiagnostics for both ForEach and Table.
  • Keep keyed runtime effects (lifecycle, tasks, Observation) single-mounted by suppressing side-effects during measurement passes.
  • Validate exact ForEach output before timing it in performance fixtures; restore the previously disabled VStack vs LazyVStack comparison.

Acceptance criteria coverage

  • VStack, HStack, LazyVStack, LazyHStack, nested ForEach in ForEach, List, and Table render every expected element.
  • Empty data renders an empty buffer without builder substitutes.
  • State, lifecycle, tasks, observation, focus, and selection follow keyed IDs across insert, delete, and reorder.
  • Removed IDs release their runtime records (lifecycle disappear, task cancel, observation deregister); duplicate IDs emit a deterministic diagnostic.
  • Performance fixtures validate exact output before measuring work.
  • Swift 6.0 build, tests, and lint are warning-free locally (1301 tests, 1 pre-existing known issue from [P0-09] Preserve subtree liveness across EquatableView cache hits #14). DocC warning status is verified by CI.

Test plan

phranck added 15 commits July 20, 2026 22:41
- route generated content through a real private view core
- derive child render identities from explicit collection keys
- replace the empty-output known issue with regression coverage
- expand child providers between static tuple siblings
- preserve structural scopes around type-erased children
- cover horizontal ForEach composition
- preserve row state across collection reorder
- confirm removed keyed state is released after the render pass
- expose isolated harness state counts for runtime assertions
- flatten active conditional branches into parent layouts
- expand nested providers produced by native builder arrays
- verify regular and lazy stack composition
- derive each extracted row context from its ForEach ID
- preserve row State across list reorder
- cover the List-specific rendering path
- move the focused cursor with reordered rows
- recover onto a selectable row after removal
- keep List and Table selection actions bound to the focused ID
- materialize keyed collection entries with stable occurrence identities
- emit deterministic per-runtime duplicate ID diagnostics
- verify duplicate ForEach output and diagnostics
- render table rows from the shared keyed snapshot
- preserve raw IDs for focus and selection tracking
- report duplicate table IDs without dropping rows
- drive characterization renders through the complete runtime pass
- prove lifecycle callbacks survive reorder and clean up on removal
- prove focus and actions follow collection IDs
- suppress lifecycle and task effects during measurement passes
- verify tasks, Observation, lifecycle, and focus follow item IDs
- confirm removed collection identities release runtime records
- enable the collection performance fixtures
- assert exact regular and lazy stack layout before timing
- preserve linear scaling and comparison checks
- exercise the public collection render path
- require an empty buffer without builder substitutes
- preserve state and runtime effects when new IDs are inserted
- keep focus and selection attached through insert plus reorder
- verify inserted task and Observation records clean up
- outer ForEach over groups, inner ForEach over per-group items
- assert all items of all groups render in order inside a VStack
- closes the nested-builders gap in issue #12 acceptance criteria
- drive Table+ForEach through insert, reorder, delete, and re-add
- verify the selection marker stays on the keyed ID across mutations
- confirm the binding is not cleared when the selected ID is removed
- extend issue #12 selection coverage to the full render path
phranck added a commit that referenced this pull request Jul 20, 2026
- move the cancel signal and trace out of withTaskCancellationHandler.onCancel
- onCancel is scheduled as a separate job and is not guaranteed to run before the test awaits the signal under CI load, causing a 60s timeout on macOS and Linux
- Task.sleep throws CancellationError when the row task is cancelled, so recording after try? await Task.sleep with a Task.isCancelled check is deterministic
- fixes CI failure on PR #54 for issue #12
phranck added a commit that referenced this pull request Jul 20, 2026
- move the cancel signal and trace out of withTaskCancellationHandler.onCancel into the task body
- onCancel is scheduled as a separate job and is not guaranteed to run before the test awaits the signal under CI load, causing a 60s timeout on macOS and Linux
- replace Task.sleep(nanoseconds: UInt64.max) with a bounded 1e12 ns duration: on the Swift 6.0.3 CI toolchain, UInt64.max overflows the internal Duration conversion and the sleep returns immediately, so the task body completes before the row is cancelled and the cancel signal is never recorded
- Task.sleep throws CancellationError when the row task is cancelled, so recording after try? await Task.sleep with a Task.isCancelled check is deterministic
- fixes CI failure on PR #54 for issue #12
@phranck
phranck force-pushed the issue/12-keyed-dynamic-collections branch from ce0bbb3 to ec03210 Compare July 20, 2026 21:01
- the .task closure inherits @mainactor from the render path, so Task.sleep suspends on the main actor and the cancellation notification can be starved under heavy main-actor load from parallel tests in CI, causing a 60s timeout on macOS and Linux
- hop to a nonisolated async function so the sleep runs on the global executor and cancellation is processed independently of main-actor scheduling
- Task.isCancelled checks the current task's flag, which is the same task even when executing a nonisolated function
- use a bounded 1e12 ns sleep (1000 s, well within Int64) instead of UInt64.max to avoid Duration conversion overflow on the Swift 6.0.3 toolchain
- TraceRecorder and AsyncSignal are both Sendable and thread-safe, so recording the cancel trace and signalling from the global executor is safe
- fixes CI failure on PR #54 for issue #12
@phranck
phranck force-pushed the issue/12-keyed-dynamic-collections branch from ec03210 to f9ba1f2 Compare July 20, 2026 21:25
- remove stale ForEach.body Never symbol (body is now some View)
- classify new childInfos/childViews symbols on ConditionalView, ViewArray, and ForEach as implementation leaks under issue #35
- add entries to both review-policy.json and compatibility-manifest.json
@phranck
phranck force-pushed the issue/12-keyed-dynamic-collections branch from 0737089 to baa5b28 Compare July 20, 2026 21:50
- Fix mangled childViews override: the compiler emits ChildView with the
  word-substitution form (05ChildG0V), not the fully-spelled 9ChildViewV
  that was hand-derived from the childInfos symbol. The exact-USR check
  rejected the stale form as an unknown TUIkit symbol.
- Add the ForEach.body implementationLeak override. body changed from
  Never to some View, but only the old body:Never symbol was removed;
  the new opaque-result symbol (4bodyQrvp) was left unclassified.
- Regenerate compatibility-manifest.json from the tool so it matches the
  current API snapshots byte-for-byte.
@phranck
phranck merged commit 884a2e0 into main Jul 21, 2026
6 checks passed
@phranck
phranck deleted the issue/12-keyed-dynamic-collections branch July 21, 2026 07:53
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.

[P0-07] Render ForEach and keyed dynamic collections correctly

1 participant