ReviewOS

also looking at this

craft-native/craft

fix(native): retain keyed controls and correct map and tab chrome

#368
Merged chris wants to merge codex/native-repeated-selector-identity into main
32 files +449 -100
.github/workflows/benchmarks.ymlmodified+2-2
Changes to .github/workflows/benchmarks.yml
@@ -35,9 +35,9 @@ jobs:
3535 fetch-depth: 0
3636
3737 - name: Setup Zig
38 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
38 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
3939 with:
40 version: '0.11.93'
40 version: '0.11.94'
4141 - name: Install Linux dependencies
4242 run: |
4343 sudo apt-get update
.github/workflows/binary-size.ymlmodified+2-2
Changes to .github/workflows/binary-size.yml
@@ -49,9 +49,9 @@ jobs:
4949 fetch-depth: 0
5050
5151 - name: Setup Zig
52 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
52 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
5353 with:
54 version: '0.11.93'
54 version: '0.11.94'
5555 - name: Install Linux dependencies
5656 run: |
5757 sudo apt-get update
.github/workflows/ci.ymlmodified+26-26
Changes to .github/workflows/ci.yml
@@ -33,9 +33,9 @@ jobs:
3333 steps:
3434 - uses: actions/checkout@v6
3535 - name: Install the managed Android toolchain
36 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
36 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
3737 with:
38 version: '0.11.93'
38 version: '0.11.94'
3939 install: 'false'
4040 packages: bun.com@1.4.3 git-scm.org@2.53.0 openjdk.org@17.0.20.8 android.com/cmdline-tools@14742923.0.0
4141 - name: Boot and dispose the stock Play image through the SDK
@@ -58,9 +58,9 @@ jobs:
5858 steps:
5959 - uses: actions/checkout@v6
6060 - name: Install the identical managed Android toolchain
61 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
61 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
6262 with:
63 version: '0.11.93'
63 version: '0.11.94'
6464 install: 'false'
6565 packages: bun.com@1.4.3 git-scm.org@2.53.0 openjdk.org@17.0.20.8 android.com/cmdline-tools@14742923.0.0
6666 - name: Verify bounded budget validation and normal readiness contracts
@@ -93,9 +93,9 @@ jobs:
9393 - uses: actions/checkout@v6
9494
9595 - name: Setup Pantry (provides Bun, Zig, etc.)
96 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
96 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
9797 with:
98 version: '0.11.93'
98 version: '0.11.94'
9999
100100 - name: Verify Zig installation
101101 run: zig version
@@ -178,9 +178,9 @@ jobs:
178178 - uses: actions/checkout@v6
179179
180180 - name: Setup Pantry (provides Bun and Zig)
181 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
181 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
182182 with:
183 version: '0.11.93'
183 version: '0.11.94'
184184
185185 - name: First-party Zig dependencies
186186 uses: ./.github/actions/first-party-zig-deps
@@ -237,9 +237,9 @@ jobs:
237237 - uses: actions/checkout@v6
238238
239239 - name: Setup Pantry (provides Bun, Zig, etc.)
240 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
240 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
241241 with:
242 version: '0.11.93'
242 version: '0.11.94'
243243
244244 - name: Install dependencies
245245 run: bun install
@@ -283,9 +283,9 @@ jobs:
283283 - uses: actions/checkout@v6
284284
285285 - name: Setup Pantry (provides Bun, Zig, etc.)
286 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
286 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
287287 with:
288 version: '0.11.93'
288 version: '0.11.94'
289289
290290 - name: Install dependencies
291291 run: bun install
@@ -310,9 +310,9 @@ jobs:
310310 - uses: actions/checkout@v6
311311
312312 - name: Setup Pantry (provides Bun, Zig, etc.)
313 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
313 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
314314 with:
315 version: '0.11.93'
315 version: '0.11.94'
316316
317317 - name: Install XcodeGen
318318 run: brew install xcodegen
@@ -352,9 +352,9 @@ jobs:
352352 - uses: actions/checkout@v6
353353
354354 - name: Setup Pantry (provides Bun, Zig, etc.)
355 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
355 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
356356 with:
357 version: '0.11.93'
357 version: '0.11.94'
358358
359359 - name: Setup Java
360360 uses: actions/setup-java@v6
@@ -403,9 +403,9 @@ jobs:
403403 - uses: actions/checkout@v6
404404
405405 - name: Setup Pantry (provides Bun, Zig, etc.)
406 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
406 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
407407 with:
408 version: '0.11.93'
408 version: '0.11.94'
409409
410410 - name: Install dependencies
411411 run: bun install
@@ -422,9 +422,9 @@ jobs:
422422 - uses: actions/checkout@v6
423423
424424 - name: Setup Pantry (provides Bun, Zig, etc.)
425 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
425 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
426426 with:
427 version: '0.11.93'
427 version: '0.11.94'
428428
429429 - name: Install dependencies
430430 run: bun install
@@ -449,9 +449,9 @@ jobs:
449449 - uses: actions/checkout@v6
450450
451451 - name: Setup Pantry (provides Bun, Zig, etc.)
452 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
452 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
453453 with:
454 version: '0.11.93'
454 version: '0.11.94'
455455
456456 - name: Install dependencies
457457 run: bun install
@@ -526,9 +526,9 @@ jobs:
526526 - uses: actions/checkout@v6
527527
528528 - name: Setup Pantry (provides Bun, Zig, etc.)
529 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
529 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
530530 with:
531 version: '0.11.93'
531 version: '0.11.94'
532532
533533 - name: Install dependencies
534534 run: bun install
@@ -584,9 +584,9 @@ jobs:
584584 fetch-depth: 0
585585
586586 - name: Setup Pantry (provides Bun, Zig, etc.)
587 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
587 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
588588 with:
589 version: '0.11.93'
589 version: '0.11.94'
590590
591591 - name: Install Dependencies
592592 run: bun install
.github/workflows/mobile-e2e.ymlmodified+14-14
Changes to .github/workflows/mobile-e2e.yml
@@ -76,7 +76,7 @@ on:
7676 android_suite:
7777 description: Android native fixture coverage for the android-navigation scope
7878 type: choice
79 options: [focused, full, system-bars, maps, accessibility]
79 options: [focused, full, system-bars, maps, accessibility, identity]
8080 default: focused
8181 selectors:
8282 description: Optional XCTest selector for focused native iOS rendering or navigation
@@ -113,9 +113,9 @@ jobs:
113113 - uses: actions/checkout@v6
114114
115115 - name: Setup Pantry (provides Bun, Zig, etc.)
116 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
116 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
117117 with:
118 version: '0.11.93'
118 version: '0.11.94'
119119
120120 - name: Install dependencies
121121 run: bun install --frozen-lockfile
@@ -167,7 +167,7 @@ jobs:
167167 uses: actions/checkout@v6
168168 with:
169169 repository: stacksjs/stx
170 ref: 3dd3240fc9a64f360e9690eee50c483be3325705
170 ref: 0d85afbab4b569ac2f09113a6c9a5d11d926145f
171171 path: .stx-compiler
172172
173173 - name: Install stx compiler dependencies
@@ -267,9 +267,9 @@ jobs:
267267 - uses: actions/checkout@v6
268268
269269 - name: Setup Pantry (provides Bun, Zig, etc.)
270 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
270 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
271271 with:
272 version: '0.11.93'
272 version: '0.11.94'
273273
274274 - name: Install XcodeGen
275275 run: brew install xcodegen
@@ -286,7 +286,7 @@ jobs:
286286 uses: actions/checkout@v6
287287 with:
288288 repository: stacksjs/stx
289 ref: 3dd3240fc9a64f360e9690eee50c483be3325705
289 ref: 0d85afbab4b569ac2f09113a6c9a5d11d926145f
290290 path: .stx-compiler
291291
292292 - name: Install stx compiler dependencies
@@ -400,9 +400,9 @@ jobs:
400400 - uses: actions/checkout@v6
401401
402402 - name: Setup Pantry (provides Bun, Zig, etc.)
403 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
403 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
404404 with:
405 version: '0.11.93'
405 version: '0.11.94'
406406
407407 - name: Setup Java
408408 uses: actions/setup-java@v6
@@ -434,7 +434,7 @@ jobs:
434434 uses: actions/checkout@v6
435435 with:
436436 repository: stacksjs/stx
437 ref: 3dd3240fc9a64f360e9690eee50c483be3325705
437 ref: 0d85afbab4b569ac2f09113a6c9a5d11d926145f
438438 path: .stx-compiler
439439
440440 - name: Install Android stx compiler dependencies
@@ -566,9 +566,9 @@ jobs:
566566 - uses: actions/checkout@v6
567567
568568 - name: Setup Pantry (provides Bun, Zig, etc.)
569 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
569 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
570570 with:
571 version: '0.11.93'
571 version: '0.11.94'
572572
573573 - name: Cache Zig artifacts
574574 uses: actions/cache@v5
@@ -618,7 +618,7 @@ jobs:
618618 api-level: ${{ fromJSON((inputs.scope != 'android-navigation' || inputs.android_suite == 'full') && '[34,35]' || inputs.android_suite == 'system-bars' && '[35]' || '[34]') }}
619619 env:
620620 CRAFT_ANDROID_NATIVE_SUITE: ${{ inputs.scope != 'android-navigation' && 'full' || inputs.android_suite || 'focused' }}
621 CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'accessibility' && 'dev.craft.navigationtest.NativeNavigationTest#nativeTestIdsRemainQueryableWithoutReplacingVisibleLabels' || inputs.scope == 'android-navigation' && inputs.android_suite == 'maps' && 'dev.craft.navigationtest.NativeMapTest#keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible,dev.craft.navigationtest.NativeMapTest#nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle,dev.craft.navigationtest.NativeMapTest#showingTheLocationDotDoesNotRequestPermissionDuringRendering,dev.craft.navigationtest.NativeMapTest#controlledRegionEchoPreservesTheCameraAfterANativeDrag,dev.craft.navigationtest.NativeMapTest#backgroundAndResumeKeepTheNativeMapAndCamera' || inputs.scope == 'android-navigation' && inputs.android_suite == 'system-bars' && 'dev.craft.navigationtest.NativeNavigationTest#nativeScreenBackgroundOptionAppliesAndResets' || inputs.scope == 'android-navigation' && inputs.android_suite != 'full' && 'dev.craft.navigationtest.NativeNavigationTest#outgoingTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#thirdTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#cancelledSwipeTargetIsVisibleWhenSelectedLater,dev.craft.navigationtest.NativeNavigationTest#completedReverseSwipeKeepsRetainedTabVisible,dev.craft.navigationtest.NativeNavigationTest#nativePullToRefreshSurvivesViewReattachment,dev.craft.navigationtest.NativeNavigationTest#pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack,dev.craft.navigationtest.NativeFlatListTest#refreshAppearanceUsesTheScreenContrastFallback,dev.craft.navigationtest.NativeNavigationTest#nativeDatePickerKeepsFollowingControlsMounted' || '' }}
621 CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'accessibility' && 'dev.craft.navigationtest.NativeNavigationTest#nativeTestIdsRemainQueryableWithoutReplacingVisibleLabels' || inputs.scope == 'android-navigation' && inputs.android_suite == 'maps' && 'dev.craft.navigationtest.NativeMapTest#keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible,dev.craft.navigationtest.NativeMapTest#nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle,dev.craft.navigationtest.NativeMapTest#showingTheLocationDotDoesNotRequestPermissionDuringRendering,dev.craft.navigationtest.NativeMapTest#controlledRegionEchoPreservesTheCameraAfterANativeDrag,dev.craft.navigationtest.NativeMapTest#backgroundAndResumeKeepTheNativeMapAndCamera' || inputs.scope == 'android-navigation' && inputs.android_suite == 'identity' && 'dev.craft.navigationtest.NativeNavigationTest#repeatedTestIdsKeepDistinctProtocolViewsAcrossReorders' || inputs.scope == 'android-navigation' && inputs.android_suite == 'system-bars' && 'dev.craft.navigationtest.NativeNavigationTest#nativeScreenBackgroundOptionAppliesAndResets' || inputs.scope == 'android-navigation' && inputs.android_suite != 'full' && 'dev.craft.navigationtest.NativeNavigationTest#outgoingTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#thirdTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#cancelledSwipeTargetIsVisibleWhenSelectedLater,dev.craft.navigationtest.NativeNavigationTest#completedReverseSwipeKeepsRetainedTabVisible,dev.craft.navigationtest.NativeNavigationTest#nativePullToRefreshSurvivesViewReattachment,dev.craft.navigationtest.NativeNavigationTest#pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack,dev.craft.navigationtest.NativeFlatListTest#refreshAppearanceUsesTheScreenContrastFallback,dev.craft.navigationtest.NativeNavigationTest#nativeDatePickerKeepsFollowingControlsMounted' || '' }}
622622 if: ${{ inputs.scope != 'ios-navigation' && inputs.scope != 'ios-render' }}
623623 runs-on: ubuntu-latest
624624 timeout-minutes: 35
@@ -642,7 +642,7 @@ jobs:
642642 - uses: actions/checkout@v6
643643 with:
644644 repository: stacksjs/stx
645 ref: 3dd3240fc9a64f360e9690eee50c483be3325705
645 ref: 0d85afbab4b569ac2f09113a6c9a5d11d926145f
646646 path: .stx-compiler
647647 - run: bun install --no-save
648648 working-directory: .stx-compiler
.github/workflows/release.ymlmodified+10-10
Changes to .github/workflows/release.yml
@@ -69,9 +69,9 @@ jobs:
6969 fetch-depth: 0
7070
7171 - name: Setup Pantry (provides Bun, Zig, etc.)
72 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
72 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
7373 with:
74 version: '0.11.93'
74 version: '0.11.94'
7575
7676 - name: Install System Dependencies
7777 if: matrix.platform.deps != ''
@@ -344,9 +344,9 @@ jobs:
344344 # publish the Zig package here: a failed second leg would otherwise make
345345 # an incomplete version visible in the pantry registry.
346346 - name: Stage draft release
347 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
347 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
348348 with:
349 version: '0.11.93'
349 version: '0.11.94'
350350 install: 'false'
351351 release: 'true'
352352 release-draft: 'true'
@@ -435,9 +435,9 @@ jobs:
435435 # paths; it does not rebuild a platform binary. The complete GitHub
436436 # release is public before this registry version can become visible.
437437 - name: Publish Zig package
438 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
438 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
439439 with:
440 version: '0.11.93'
440 version: '0.11.94'
441441 install: 'false'
442442 publish: 'zig'
443443 package-dir: packages/zig
@@ -644,9 +644,9 @@ jobs:
644644 fetch-depth: 0
645645
646646 - name: Setup Pantry (provides Bun, Zig, etc.)
647 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
647 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
648648 with:
649 version: '0.11.93'
649 version: '0.11.94'
650650
651651 - name: Install Dependencies
652652 run: bun install --frozen-lockfile
@@ -731,9 +731,9 @@ jobs:
731731 fetch-depth: 0
732732
733733 - name: Setup Pantry (provides Bun, Zig, etc.)
734 uses: pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231 # v0.11.93
734 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
735735 with:
736 version: '0.11.93'
736 version: '0.11.94'
737737 - uses: actions/setup-node@v6
738738 with:
739739 node-version: '24'
docs/guides/native-stx.mdmodified+4-3
Changes to docs/guides/native-stx.md
@@ -61,9 +61,10 @@ capability APIs from the host.
6161The maintained Mobile E2E workflow checks out one exact stx compiler commit for
6262both simulator hosts. Update the iOS and Android refs together; the workflow
6363contract test rejects compiler skew between them.
64The current pair is pinned to `3dd3240fc9a64f360e9690eee50c483be3325705`,
65which keeps a container's testID around a library component, draws `Checkbox`
66natively and keeps a style object's `transform` and `shadowOffset`.
64The current pair is pinned to `0d85afbab4b569ac2f09113a6c9a5d11d926145f`,
65which preserves authored reconciliation keys in created and cloned native nodes.
66This is the normally published STX `0.2.573` compiler. The native fixtures
67qualify its mutation metadata together with both native models.
6768
6869The host-neutral layout contract lives in Craft's Zig runtime at
6970`packages/zig/src/native_layout.zig`. It defines the shared row/column flex
packages/android/README.mdmodified+2-1
Changes to packages/android/README.md
@@ -111,7 +111,8 @@ API 35 image took 943,520 ms in the retained diagnostic run
111111[39113277828](https://github.com/craft-native/craft/actions/runs/39113277828).
112112This is OS provisioning before app installation; app and performance budgets
113113are unchanged. Both modes require a readable event-log stream, SettingsProvider,
114the package manager and System UI to be ready. Logs record the budget, actual
114the package manager and System UI to be ready, with HOME resolved beyond the
115automatic Android SDK setup activity. Logs record the budget, actual
115116boot duration, host CPU interval, selected image, acceleration
116117probe, process identity and boot result. Disposal reaps only the owned process
117118and deletes only its disposable AVD; the managed SDK and other devices remain
packages/android/fixtures/native-navigation/NativeMutationTest.ktmodified+24-0
Changes to packages/android/fixtures/native-navigation/NativeMutationTest.kt
@@ -53,6 +53,30 @@ class NativeMutationTest {
5353 assertNull(removed.document)
5454 }
5555
56 @Test
57 fun preservesAuthoredKeysAcrossImportCreationUpdatesAndMoves() {
58 val document = CraftNativeMutationDocument()
59 document.replace(JSONObject("""
60 {"id":"root","type":"View","key":"root-key","children":[
61 {"id":"existing","type":"TextInput","key":"draft-key","props":{"value":"draft"}}
62 ]}
63 """.trimIndent()))
64 assertEquals("draft-key", document.node("existing")?.getString("key"))
65 val result = document.apply(batch(1, """
66 [
67 {"op":"createNode","id":"created","node":{"type":"View","key":"created-key"}},
68 {"op":"insertChild","parentId":"root","childId":"created","index":1},
69 {"op":"updateNode","id":"existing","patch":{"props":{"value":"edited"}}},
70 {"op":"moveChild","parentId":"root","childId":"created","index":0}
71 ]
72 """.trimIndent()))
73 val rendered = requireNotNull(result.document)
74 assertEquals("root-key", rendered.getString("key"))
75 assertEquals("created-key", rendered.getJSONArray("children").getJSONObject(0).getString("key"))
76 assertEquals("draft-key", rendered.getJSONArray("children").getJSONObject(1).getString("key"))
77 assertEquals("edited", document.node("existing")?.getJSONObject("props")?.getString("value"))
78 }
79
5680 @Test
5781 fun rejectsWholeBatchWithDeterministicFailure() {
5882 val document = CraftNativeMutationDocument()
packages/android/fixtures/native-navigation/NativeNavigationTest.ktmodified+51-0
Changes to packages/android/fixtures/native-navigation/NativeNavigationTest.kt
@@ -773,6 +773,57 @@ class NativeNavigationTest {
773773 }
774774 }
775775
776 @Test
777 fun repeatedTestIdsKeepDistinctProtocolViewsAcrossReorders() {
778 val activity = instrumentation.startActivitySync(
779 Intent(instrumentation.targetContext, MainActivity::class.java)
780 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
781 ) as MainActivity
782 fun rows(): List<View> {
783 val list = awaitView(activity, "identity-list") { candidate ->
784 val rendered = mutableListOf<View>()
785 findAll(candidate, "identity-row", rendered)
786 rendered.size == 2
787 }
788 val matches = mutableListOf<View>()
789 instrumentation.runOnMainSync {
790 findAll(list, "identity-row", matches)
791 }
792 assertEquals("Repeated test selectors must identify two separate rows", 2, matches.size)
793 return matches
794 }
795 try {
796 click(activity, "open-identity")
797 awaitView(activity, "identity-title")
798 val original = rows()
799 val alpha = original[0]
800 val beta = original[1]
801 val alphaInput = find(alpha, "identity-input") as EditText
802 val betaInput = find(beta, "identity-input") as EditText
803 assertTrue("Repeated selectors aliased separate inputs", alphaInput !== betaInput)
804 instrumentation.runOnMainSync {
805 betaInput.requestFocus()
806 betaInput.setText("Beta private draft")
807 }
808 instrumentation.waitForIdleSync()
809 click(activity, "identity-reverse")
810 awaitView(activity, "identity-list") { list ->
811 find(list, "identity-row")?.let { find(it, "identity-label-beta") != null } == true
812 }
813 instrumentation.waitForIdleSync()
814 val reversed = rows()
815 assertSame("Reordered Beta row lost its protocol identity", beta, reversed[0])
816 assertSame("Reordered Alpha row lost its protocol identity", alpha, reversed[1])
817 assertSame("Reordering replaced the focused native input", betaInput, find(reversed[0], "identity-input"))
818 assertEquals("Beta private draft", betaInput.text.toString())
819 assertEquals("The second row received another row's draft", "", alphaInput.text.toString())
820 assertTrue("Reordering dropped native focus", betaInput.isFocused)
821 } finally {
822 instrumentation.runOnMainSync { activity.finishAndRemoveTask() }
823 instrumentation.waitForIdleSync()
824 }
825 }
826
776827 @Test
777828 fun outgoingTabTapWinsOverSettlingSwipe() {
778829 assertTabTapInterruptsSettlingSwipe(0, "navigation-home-state", "Home state: Ada/1")
packages/android/fixtures/native-navigation/README.mdmodified+1-1
Changes to packages/android/fixtures/native-navigation/README.md
@@ -105,7 +105,7 @@ Returning to a retained route reapplies native theme defaults before it becomes
105105visible, so a dark-mode change while another route is open cannot leave stale
106106colors behind.
107107CI uses `@stacksjs/stx` (`stx native compile`) pinned at
108`3dd3240fc9a64f360e9690eee50c483be3325705` (stx `0.2.544`). For an unreleased sibling
108`0d85afbab4b569ac2f09113a6c9a5d11d926145f` (stx `0.2.573`). For an unreleased sibling
109109checkout, set `STX_CLI=../stx/packages/stx/bin/cli.ts`; native support is part
110110of `@stacksjs/stx`, so do not install the historical `stx-native` package or
111111compiler. The compiler installs an initial tree atomically before sending
packages/android/scripts/test-native-navigation.tsmodified+1-0
Changes to packages/android/scripts/test-native-navigation.ts
@@ -34,6 +34,7 @@ try {
3434 mapProvider: 'maplibre',
3535 nativeScreens: {
3636 '/': 'home',
37 '/identity': 'identity',
3738 '/details': 'details',
3839 '/linked-details': 'details',
3940 '/summary': 'summary',
packages/android/src/emulator.tsmodified+8-0
Changes to packages/android/src/emulator.ts
@@ -153,8 +153,16 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
153153 // launching any app. boot_completed/System UI can precede logd's ADB
154154 // event stream; declaring readiness then loses that first health check.
155155 const events = await run([adb, '-s', serial, 'logcat', '-b', 'events', '-d', '-t', '1'], undefined, probeTimeout(), true)
156 // AOSP's first boot can report all those services ready while its
157 // automatic SDK setup activity still owns HOME. Launching HOME then
158 // opens that transient activity; after it finishes UI automation has
159 // no foreground root. Wait for the real HOME destination instead of
160 // modifying device setup or relaxing native acceptance.
161 const home = await run([adb, '-s', serial, 'shell', 'cmd', 'package', 'resolve-activity', '--brief', '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.HOME'], undefined, probeTimeout(), true)
162 const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? ''
156163 booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0
157164 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim())
165 && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !homeActivity.startsWith('com.android.sdksetup/')
158166 }
159167 }
160168 const checkedAt = Date.now()
packages/android/src/index.test.tsmodified+2-1
Changes to packages/android/src/index.test.ts
@@ -558,7 +558,8 @@ describe('Craft Android builder', () => {
558558 expect(nativeActivity).toContain('if (!current.contains(message))')
559559 expect(nativeActivity).toContain('joinToString(", ")')
560560 expect(nativeActivity).toContain('screen.controls.putAll(next)')
561 expect(nativeActivity).toContain('if (layout.getChildAt(index) !== childView)')
561 expect(nativeActivity).toContain('if (childView.parent !== layout)')
562 expect(nativeActivity).toContain('desired.forEach(layout::bringChildToFront)')
562563 expect(nativeActivity).toContain('val modal = screen.modalVisibility.entries.lastOrNull')
563564 expect(nativeActivity).toContain('val suppressDismiss = !visible && consumeDismissSuppression()')
564565 expect(nativeActivity).toContain('(modal.key as? CraftNativeModalView)?.requestClose()')
packages/android/templates/CraftNativeMutation.kt.templatemodified+7-3
Changes to packages/android/templates/CraftNativeMutation.kt.template
@@ -30,7 +30,8 @@ class CraftNativeMutationDocument {
3030 var events: JSONObject,
3131 var text: MutableList<String>,
3232 var children: MutableList<String>,
33 var parent: String?
33 var parent: String?,
34 val key: String? = null
3435 ) {
3536 fun duplicate(): Node = copy(
3637 props = JSONObject(props.toString()),
@@ -175,7 +176,8 @@ class CraftNativeMutationDocument {
175176 dictionary(value.opt("events")),
176177 textChildren(if (value.has("children")) value.opt("children") else value.opt("text")),
177178 mutableListOf(),
178 null
179 null,
180 key = value.opt("key") as? String
179181 )
180182 return if (makeRoot) id else root
181183 }
@@ -299,6 +301,7 @@ class CraftNativeMutationDocument {
299301 .put("style", node.style)
300302 .put("events", node.events)
301303 .put("children", children)
304 .also { value -> node.key?.let { value.put("key", it) } }
302305 }
303306
304307 private fun importNode(
@@ -322,7 +325,8 @@ class CraftNativeMutationDocument {
322325 dictionary(value.opt("events")),
323326 mutableListOf(),
324327 mutableListOf(),
325 parent
328 parent,
329 key = value.opt("key") as? String
326330 )
327331 nodes[id] = node
328332 for (index in 0 until children.length()) {
packages/android/templates/MainActivityNative.kt.templatemodified+17-8
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -4937,8 +4937,11 @@ class MainActivity : AppCompatActivity() {
49374937 val explicitKey = authoredKey(node, props)
49384938 // Descendants are scoped to this retained identity, not its sibling
49394939 // position. Moving a keyed parent must retain its nested inputs too.
4940 // A test selector may intentionally identify several list rows. The
4941 // compiler's unique protocol ID must win over that legacy fallback.
49404942 val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
49414943 ?: protocolId?.let { "id:$it" }
4944 ?: props.optString("testID").takeIf { it.isNotBlank() }?.let { "${path.substringBeforeLast('.', "")}/$it" }
49424945 ?: path
49434946 val previous = previousControls[identity]
49444947 val reusableInput = previous as? EditText
@@ -5824,7 +5827,7 @@ class MainActivity : AppCompatActivity() {
58245827 }
58255828
58265829 private fun authoredKey(node: JSONObject, props: JSONObject): String? {
5827 val direct = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
5830 val direct = listOf(node.optString("key"), props.optString("key"))
58285831 .firstOrNull { it.isNotBlank() }
58295832 if (direct != null) return direct
58305833 return null
@@ -5880,17 +5883,23 @@ class MainActivity : AppCompatActivity() {
58805883 }
58815884 if (craftNativeOptionalString(style.opt("flexDirection"))?.endsWith("-reverse") == true) desired.reverse()
58825885 val desiredSet = desired.toSet()
5883 for ((index, childView) in desired.withIndex()) {
5884 if (layout.getChildAt(index) !== childView) {
5885 if (index < layout.childCount && layout.getChildAt(index) !in desiredSet) layout.removeViewAt(index)
5886 for (index in layout.childCount - 1 downTo 0) {
5887 if (layout.getChildAt(index) !in desiredSet) layout.removeViewAt(index)
5888 }
5889 for (childView in desired) {
5890 if (childView.parent !== layout) {
58865891 (childView.parent as? ViewGroup)?.removeView(childView)
5887 layout.addView(childView, index)
5892 layout.addView(childView)
58885893 }
58895894 val params = childView.layoutParams as? LinearLayout.LayoutParams
58905895 ?: LinearLayout.LayoutParams(childView.layoutParams?.width ?: -1, childView.layoutParams?.height ?: -2)
58915896 childView.layoutParams = params
58925897 }
5893 while (layout.childCount > desired.size) layout.removeViewAt(layout.childCount - 1)
5898 // Reorder attached children without removing their focused inputs.
5899 // Removing and adding an existing row clears Android's native focus.
5900 if (desired.indices.any { layout.getChildAt(it) !== desired[it] }) {
5901 desired.forEach(layout::bringChildToFront)
5902 }
58945903 layout.requestLayout()
58955904 }
58965905
@@ -6573,11 +6582,11 @@ class MainActivity : AppCompatActivity() {
65736582 }
65746583
65756584 private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
6576 view.tag = fallbackLabel
6585 val testId = craftNativeOptionalString(props.opt("testID"))
6586 view.tag = testId?.takeIf { it.isNotBlank() } ?: fallbackLabel
65776587 val label = craftNativeOptionalString(props.opt("accessibilityLabel"))
65786588 ?: craftNativeOptionalString(props.opt("alt"))?.takeIf { type == "Image" || type == "ImageBackground" }
65796589 val value = craftNativeOptionalString(props.opt("accessibilityValue"))
6580 val testId = craftNativeOptionalString(props.opt("testID"))
65816590 view.contentDescription = when {
65826591 label != null && value != null -> "$label, $value"
65836592 label != null -> label
packages/ios/fixtures/native-navigation/Home.stxmodified+1-0
Changes to packages/ios/fixtures/native-navigation/Home.stx
@@ -568,6 +568,7 @@ function openDetailsThenUpdateBackground() {
568568 <Button testID="keyed-list-reorder" @click="reorderNavigationRows()">Reorder rows</Button>
569569 <Text testID="navigation-home-state" :text="'Home state: ' + navigationName + '/' + navigationCount" />
570570 <Button testID="navigation-increment" @click="incrementNavigation()">Increment navigation state</Button>
571 <Button testID="open-identity" @click="globalThis.craft.navigation.open('/identity')">Repeated selector identity</Button>
571572 <Button testID="navigation-open-details" @click="openDetails()">Open navigation details</Button>
572573 <Button testID="navigation-check-lifecycle" @click="verifyNavigationLifecycle()">Check details lifecycle</Button>
573574 <Text testID="navigation-lifecycle" :text="navigationLifecycle" />
packages/ios/fixtures/native-navigation/Identity.stxadded+21-0
Changes to packages/ios/fixtures/native-navigation/Identity.stx
@@ -0,0 +1,21 @@
1<script>
2const rows = state([{ id: 'alpha', label: 'Alpha' }, { id: 'beta', label: 'Beta' }])
3const drafts = state({ alpha: '', beta: '' })
4function changeDraft(id, event) {
5 drafts.set({ ...drafts(), [id]: event.text })
6}
7function reverseRows() {
8 rows.set([...rows()].reverse())
9}
10</script>
11
12<SafeAreaView style={{"flex":1,"padding":16}}>
13 <Text testID="identity-title">Repeated selectors</Text>
14 <View testID="identity-list">
15 <View :for="row in rows" :key="row.id" testID="identity-row" style={{"height":72}}>
16 <Text :testID="'identity-label-' + row.id" :text="row.label" />
17 <TextInput testID="identity-input" :accessibilityLabel="row.label + ' draft'" :value="drafts()[row.id]" @input="changeDraft(row.id, $event)" />
18 </View>
19 </View>
20 <Button testID="identity-reverse" @click="reverseRows()">Reverse rows</Button>
21</SafeAreaView>
packages/ios/fixtures/native-navigation/NativeNavigationUnitTests.swiftmodified+84-0
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUnitTests.swift
@@ -194,6 +194,44 @@ final class NativeNavigationUnitTests: XCTestCase {
194194 XCTAssertEqual(link.gestureRecognizers?.filter { $0 is UITapGestureRecognizer }.count, 1)
195195 }
196196
197 func testRepeatedSelectorsRetainDistinctProtocolViewsAcrossReorders() throws {
198 let controller = CraftNativeScreenController(config: CraftConfig())
199 window.rootViewController = controller
200 controller.loadViewIfNeeded()
201 window.makeKeyAndVisible()
202 controller.view.layoutIfNeeded()
203 func document(_ ids: [String]) -> [String: Any] {
204 ["id": "identity-root", "type": "View", "children": ids.map { id in
205 ["id": "row-\(id)", "type": "View", "props": ["testID": "repeated-row"],
206 "children": [["id": "input-\(id)", "type": "TextInput", "props": ["testID": "repeated-input", "accessibilityLabel": "\(id) draft"]]]]
207 }]
208 }
209 func matching(_ view: UIView, _ id: String) -> [UIView] {
210 (view.accessibilityIdentifier == id ? [view] : []) + view.subviews.flatMap { matching($0, id) }
211 }
212 controller.render(document(["alpha", "beta"]))
213 controller.view.layoutIfNeeded()
214 let original = matching(controller.view, "repeated-row")
215 XCTAssertEqual(original.count, 2)
216 guard original.count == 2 else { return }
217 let alpha = original[0]
218 let beta = original[1]
219 let betaInput = try XCTUnwrap(matching(beta, "repeated-input").first as? UITextField)
220 XCTAssertTrue(betaInput.becomeFirstResponder())
221 betaInput.text = "Beta private draft"
222 betaInput.sendActions(for: .editingChanged)
223 controller.render(document(["beta", "alpha"]))
224 controller.view.layoutIfNeeded()
225 let reordered = matching(controller.view, "repeated-row")
226 XCTAssertEqual(reordered.count, 2)
227 guard reordered.count == 2 else { return }
228 XCTAssertTrue(reordered[0] === beta, "the repeated selector overrode Beta's protocol identity")
229 XCTAssertTrue(reordered[1] === alpha, "the repeated selector overrode Alpha's protocol identity")
230 XCTAssertTrue(matching(reordered[0], "repeated-input").first === betaInput)
231 XCTAssertEqual(betaInput.text, "Beta private draft")
232 XCTAssertTrue(betaInput.isFirstResponder, "reordering replaced the focused native input")
233 }
234
197235 func testIdlePreparationKeepsSelectionAndCurrentStackIntact() async throws {
198236 let home = try XCTUnwrap(stack("/"))
199237 let root = home.topViewController
@@ -381,6 +419,52 @@ final class NativeNavigationUnitTests: XCTestCase {
381419 XCTAssertNil(CraftChrome.shared.tabAccessory)
382420 }
383421
422 func testNativePushKeepsItsAccessoryAfterTabPreparation() async throws {
423 await displayWait("the Third tab is prepared") { [self] in stack("/third") != nil }
424 let home = try XCTUnwrap(stack("/"))
425 let root = try XCTUnwrap(home.topViewController as? CraftNativeScreenController)
426 func button(in view: UIView) -> UIButton? {
427 if view.accessibilityIdentifier == "navigation-open-details" { return view as? UIButton }
428 return view.subviews.lazy.compactMap { button(in: $0) }.first
429 }
430 let open = try XCTUnwrap(button(in: root.view))
431 open.sendActions(for: .touchUpInside)
432 await displayWait("the compiled button pushes Details") {
433 home.topViewController !== root && home.transitionCoordinator == nil
434 }
435 await renderFrames()
436 XCTAssertEqual(CraftChrome.shared.selected, "/")
437 XCTAssertEqual(CraftChrome.shared.tabAccessory?.id, "/capabilities")
438
439 let background = try XCTUnwrap(stack("/third"))
440 XCTAssertTrue(background.view.isHidden)
441 let prepared = try XCTUnwrap(background.topViewController as? CraftNativeScreenController)
442 // UIKit can deliver the prepared tab's first appearance after the
443 // foreground push has finished. That callback must not claim chrome.
444 prepared.viewDidAppear(false)
445 XCTAssertEqual(CraftChrome.shared.tabAccessory?.id, "/capabilities")
446 XCTAssertEqual(CraftChrome.shared.selected, "/")
447 }
448
449 func testDisappearingHiddenNativeTabCannotClearTheVisibleAccessory() async throws {
450 shell.open("/linked-details")
451 let home = try XCTUnwrap(stack("/"))
452 await displayWait("details accessory is visible") {
453 home.transitionCoordinator == nil && CraftChrome.shared.tabAccessory?.id == "/capabilities"
454 }
455 let background = CraftNativeScreenController(config: CraftHybrid.shared.config)
456 background.loadViewIfNeeded()
457 // A prepared or retained controller may finish disappearing after the
458 // current screen has published its chrome. It cannot clear that owner.
459 background.viewWillDisappear(false)
460 XCTAssertEqual(CraftChrome.shared.tabAccessory?.id, "/capabilities")
461 let visible = try XCTUnwrap(home.topViewController as? CraftNativeScreenController)
462 visible.viewWillDisappear(false)
463 XCTAssertNil(CraftChrome.shared.tabAccessory, "the owning screen must still clear its accessory")
464 visible.screenVisibilityChanged(true)
465 XCTAssertEqual(CraftChrome.shared.tabAccessory?.id, "/capabilities")
466 }
467
384468 func testCancelledColdSwipeRestoresTheVisibleScreensAccessory() async throws {
385469 let previous = CraftChrome.shared.tabAccessory
386470 drag(-150)
packages/ios/fixtures/native-navigation/README.mdmodified+1-1
Changes to packages/ios/fixtures/native-navigation/README.md
@@ -51,7 +51,7 @@ without relying on a screenshot.
5151
5252From the Craft repository, with `@stacksjs/stx` available as `stx` (the
5353maintained CI checkout is pinned to
54`3dd3240fc9a64f360e9690eee50c483be3325705` / stx `0.2.544`):
54`0d85afbab4b569ac2f09113a6c9a5d11d926145f` / stx `0.2.573`):
5555
5656```bash
5757bun packages/ios/scripts/test-native-navigation.ts
packages/ios/fixtures/native-navigation/native.config.jsonmodified+1-0
Changes to packages/ios/fixtures/native-navigation/native.config.json
@@ -2,6 +2,7 @@
22 "initialScreen": "home",
33 "screens": {
44 "home": "Home.stx",
5 "identity": "Identity.stx",
56 "capabilities": "Capabilities.stx",
67 "details": "Details.stx",
78 "summary": "Summary.stx"
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+60-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -822,6 +822,28 @@ final class NativeRenderUnitTests: XCTestCase {
822822 XCTAssertTrue(scroll === find(UIScrollView.self, key: "scroll", below: controller.view))
823823 }
824824
825 func testMutationPreservesAuthoredKeysAcrossImportCreationUpdatesAndMoves() throws {
826 let document = CraftNativeMutationDocument()
827 document.replace(with: [
828 "id": "root", "type": "View", "key": "root-key", "children": [
829 ["id": "existing", "type": "TextInput", "key": "draft-key", "props": ["value": "draft"]],
830 ],
831 ])
832 XCTAssertEqual(document.node("existing")?["key"] as? String, "draft-key")
833 let result = try document.apply(batch(1, [
834 ["op": "createNode", "id": "created", "node": ["type": "View", "key": "created-key"]],
835 ["op": "insertChild", "parentId": "root", "childId": "created", "index": 1],
836 ["op": "updateNode", "id": "existing", "patch": ["props": ["value": "edited"]]],
837 ["op": "moveChild", "parentId": "root", "childId": "created", "index": 0],
838 ]))
839 let rendered = try XCTUnwrap(result.document)
840 let children = try XCTUnwrap(rendered["children"] as? [[String: Any]])
841 XCTAssertEqual(rendered["key"] as? String, "root-key")
842 XCTAssertEqual(children[0]["key"] as? String, "created-key")
843 XCTAssertEqual(children[1]["key"] as? String, "draft-key")
844 XCTAssertEqual((document.node("existing")?["props"] as? [String: Any])?["value"] as? String, "edited")
845 }
846
825847 func testEveryContainerTheRendererDrawsTakesChildrenAndALeafDoesNot() throws {
826848 for type in CraftNativeMutationDocument.containerTypes.sorted() + ["svg", "pressable"] {
827849 let document = CraftNativeMutationDocument()
@@ -901,6 +923,44 @@ final class NativeRenderUnitTests: XCTestCase {
901923 XCTAssertEqual(marker.transform.b, 1, accuracy: 0.02)
902924 }
903925
926 func testMapDirectionSignsKeepALightBackdropAndTheAuthoredColor() throws {
927 for appearance in [UIUserInterfaceStyle.light, .dark] {
928 let controller = CraftNativeScreenController(config: CraftConfig())
929 controller.overrideUserInterfaceStyle = appearance
930 controller.loadViewIfNeeded()
931 controller.render([
932 "type": "Map", "props": ["key": "contrast-map"],
933 "style": ["width": 320, "height": 320],
934 "children": [["type": "MapMarker", "props": ["id": "direction", "geometry": [37.7, -119.5], "bearing": 90, "pinColor": "#365b2d"]]],
935 ])
936 let map = try XCTUnwrap(find(MKMapView.self, key: "contrast-map", below: controller.view))
937 let annotation = try XCTUnwrap(map.annotations.first)
938 let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation))
939 let image = try XCTUnwrap(marker.image)
940 let raster = UIGraphicsImageRenderer(size: image.size).image { _ in image.draw(at: .zero) }
941 let cgImage = try XCTUnwrap(raster.cgImage)
942 var pixels = [UInt8](repeating: 0, count: cgImage.width * cgImage.height * 4)
943 try pixels.withUnsafeMutableBytes { bytes in
944 let context = try XCTUnwrap(CGContext(data: bytes.baseAddress, width: cgImage.width, height: cgImage.height,
945 bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: CGColorSpaceCreateDeviceRGB(),
946 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue))
947 context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
948 }
949 var opaque = 0
950 var light = 0
951 var authored = 0
952 for offset in stride(from: 0, to: pixels.count, by: 4) where pixels[offset + 3] > 240 {
953 opaque += 1
954 let red = Int(pixels[offset]), green = Int(pixels[offset + 1]), blue = Int(pixels[offset + 2])
955 if red > 235 && green > 235 && blue > 235 { light += 1 }
956 if abs(red - 54) < 12 && abs(green - 91) < 12 && abs(blue - 45) < 12 { authored += 1 }
957 }
958 XCTAssertGreaterThan(opaque, 50)
959 XCTAssertGreaterThan(light, opaque / 3, "direction signs need an opaque light backdrop over the basemap")
960 XCTAssertGreaterThan(authored, 10, "the native direction sign ignored its authored pinColor")
961 }
962 }
963
904964 func testMapPreservesAuthoredPolylineOrderAcrossUpdatesAndMoves() throws {
905965 let controller = CraftNativeScreenController(config: CraftConfig())
906966 let window = try hostWindow()
packages/ios/fixtures/timed-input/HostApp.swiftmodified+10-1
Changes to packages/ios/fixtures/timed-input/HostApp.swift
@@ -12,7 +12,16 @@ struct HostApp: App {
1212 Text(taps.isEmpty ? "none" : taps.joined(separator: ","))
1313 .lineLimit(1)
1414 .accessibilityIdentifier("received-taps")
15 Spacer()
15 ScrollView {
16 VStack {
17 Button("Start of scroll fixture") {}
18 .accessibilityIdentifier("driver-scroll-start")
19 Color.clear.frame(height: 1600).accessibilityHidden(true)
20 Button("End of scroll fixture") {}
21 .accessibilityIdentifier("driver-scroll-end")
22 }
23 .frame(maxWidth: .infinity)
24 }
1625 HStack {
1726 ForEach(names, id: \.self) { name in
1827 Button(name) { taps.append(name) }
packages/ios/scripts/compile-templates.tsmodified+4-0
Changes to packages/ios/scripts/compile-templates.ts
@@ -259,6 +259,10 @@ try {
259259 ]) },
260260 { command: 'labelid', id: 'received-taps', text: inputSequence.join(',') },
261261 { command: 'selected', text: 'Four' },
262 { command: 'scrollto', text: 'End of scroll fixture' },
263 { command: 'expectid', text: 'driver-scroll-end' },
264 { command: 'scrollto', text: 'Start of scroll fixture' },
265 { command: 'expectid', text: 'driver-scroll-start' },
262266 ])
263267 initSimulatorDriver({ output: driverOutput, bundleId: 'dev.craft.timedinput.host',
264268 bundleIdPrefix: 'dev.craft.timedinput', projectName: 'TimedInputDriver', tabs: inputNames, script })
packages/ios/scripts/test-native-navigation.tsmodified+1-0
Changes to packages/ios/scripts/test-native-navigation.ts
@@ -66,6 +66,7 @@ try {
6666 renderer: 'native',
6767 nativeScreens: {
6868 '/': 'home',
69 '/identity': 'identity',
6970 '/details': 'details',
7071 '/linked-details': 'details',
7172 '/summary': 'summary',
packages/ios/src/index.test.tsmodified+1-1
Changes to packages/ios/src/index.test.ts
@@ -1709,7 +1709,7 @@ describe('Craft iOS builder', () => {
17091709 // A screen can ask the tab bar away while it shows; the next one says again.
17101710 expect(screen).toContain('if let hidden = options["tabBarHidden"] as? Bool {')
17111711 expect(screen).toContain('CraftChrome.shared.setHiddenByScreen(tabBarHidden)')
1712 expect(screen).toContain('CraftChrome.shared.updateTabAccessory(nil)')
1712 expect(screen).toContain('CraftChrome.shared.clearTabAccessory(ownedBy: self)')
17131713 expect(screen).toContain('if let contentSwipeBack = options["contentSwipeBack"] as? Bool {')
17141714 expect(screen).toContain('private(set) var contentSwipeBack = true')
17151715 expect(screen).toContain('if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {')
packages/ios/templates/CraftApp.swiftmodified+9-1
Changes to packages/ios/templates/CraftApp.swift
@@ -670,6 +670,7 @@ final class CraftChrome: ObservableObject {
670670
671671 @Published var tabs: [Tab] = []
672672 @Published var tabAccessory: Tab?
673 private weak var tabAccessoryOwner: AnyObject?
673674 @Published var selected: String?
674675 let tabSwipeProgress = CraftTabSwipeProgress()
675676 private(set) var tabSwipePosition: CGFloat? {
@@ -771,7 +772,7 @@ final class CraftChrome: ObservableObject {
771772 tabs[index] = next
772773 }
773774 /// A destination beside the tab pill, outside the tab stacks and swipe order.
774 func updateTabAccessory(_ item: [String: Any]?) {
775 func updateTabAccessory(_ item: [String: Any]?, owner: AnyObject? = nil) {
775776 let next: Tab?
776777 if let item, let path = item["path"] as? String,
777778 path.hasPrefix("/"), !path.hasPrefix("//"),
@@ -782,10 +783,17 @@ final class CraftChrome: ObservableObject {
782783 } else {
783784 next = nil
784785 }
786 tabAccessoryOwner = owner
785787 guard tabAccessory != next else { return }
786788 tabAccessory = next
787789 }
788790
791 /// A retained or prepared screen cannot clear another screen's chrome.
792 func clearTabAccessory(ownedBy owner: AnyObject) {
793 guard tabAccessoryOwner === owner else { return }
794 updateTabAccessory(nil)
795 }
796
789797 func setHiddenByScreen(_ hidden: Bool) {
790798 guard hiddenByScreen != hidden else { return }
791799 hiddenByScreen = hidden
packages/ios/templates/CraftNativeMutation.swiftmodified+4-0
Changes to packages/ios/templates/CraftNativeMutation.swift
@@ -29,6 +29,7 @@ struct CraftNativeMutationResult {
2929final class CraftNativeMutationDocument {
3030 private struct Node {
3131 let id: String
32 let key: String?
3233 var type: String
3334 var props: [String: Any]
3435 var style: [String: Any]
@@ -177,6 +178,7 @@ final class CraftNativeMutationDocument {
177178 }
178179 nodes[id] = Node(
179180 id: id,
181 key: value["key"] as? String,
180182 type: type,
181183 props: dictionary(value["props"]),
182184 style: dictionary(value["style"]),
@@ -330,6 +332,7 @@ final class CraftNativeMutationDocument {
330332 "style": node.style,
331333 "events": node.events,
332334 ]
335 if let key = node.key { value["key"] = key }
333336 let text: [Any] = node.text
334337 let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
335338 value["children"] = text + children
@@ -350,6 +353,7 @@ final class CraftNativeMutationDocument {
350353 let children = value["children"] as? [Any] ?? []
351354 nodes[id] = Node(
352355 id: id,
356 key: value["key"] as? String,
353357 type: value["type"] as? String ?? "View",
354358 props: props,
355359 style: dictionary(value["style"]),
packages/ios/templates/CraftNativeScreen.swiftmodified+46-17
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -1435,21 +1435,47 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
14351435
14361436 private func configureMarkerView(_ view: MKAnnotationView, id: String) {
14371437 if let bearing = markerBearings[id] {
1438 let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
1439 .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1440 // MapKit otherwise tints this as a monochrome template and loses
1441 // the contrasting route arrow when displayed over map content.
1442 view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)?
1443 .withRenderingMode(.alwaysOriginal)
1438 let color = (markerPinColors[id] ?? .systemGreen).resolvedColor(with: traitCollection)
1439 view.tintColor = color
1440 view.image = Self.markerImage(color: color, direction: true)
14441441 view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
14451442 } else {
1446 view.tintColor = markerPinColors[id] ?? .systemRed
1447 view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1448 .withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
1443 let color = (markerPinColors[id] ?? .systemRed).resolvedColor(with: traitCollection)
1444 view.tintColor = color
1445 view.image = Self.markerImage(color: color, direction: false)
14491446 view.transform = .identity
14501447 }
14511448 }
14521449
1450 private static let markerImages: NSCache<NSString, UIImage> = {
1451 let cache = NSCache<NSString, UIImage>()
1452 cache.countLimit = 64
1453 return cache
1454 }()
1455
1456 private static func markerImage(color: UIColor, direction: Bool) -> UIImage {
1457 let key = "\(direction ? "direction" : "pin"):\(color.description)" as NSString
1458 if let cached = markerImages.object(forKey: key) { return cached }
1459 let size = direction ? CGSize(width: 20, height: 24) : CGSize(width: 28, height: 28)
1460 // Explicit native drawing preserves the light backing independently
1461 // of MapKit's tint and SF Symbols' multicolor rendering preferences.
1462 let image = UIGraphicsImageRenderer(size: size).image { _ in
1463 let border = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1),
1464 cornerRadius: (size.width - 2) / 2)
1465 UIColor.white.setFill()
1466 border.fill()
1467 color.setStroke()
1468 border.lineWidth = 1.5
1469 border.stroke()
1470 let symbol = UIImage(systemName: direction ? "arrow.up" : "mappin",
1471 withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))?
1472 .withTintColor(color, renderingMode: .alwaysOriginal)
1473 symbol?.draw(in: direction ? CGRect(x: 5, y: 6, width: 10, height: 12) : CGRect(x: 7, y: 5, width: 14, height: 18))
1474 }.withRenderingMode(.alwaysOriginal)
1475 markerImages.setObject(image, forKey: key)
1476 return image
1477 }
1478
14531479 override func layoutSubviews() {
14541480 super.layoutSubviews()
14551481 if tileTemplate != nil {
@@ -4621,10 +4647,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
46214647 super.viewWillDisappear(animated)
46224648 screenVisibilityChanged(false)
46234649 stopCelebrations(in: view)
4624 // The accessory belongs to the visible native screen. Clear the
4625 // shared shell state before a hybrid/web route appears, otherwise a
4626 // destination from the outgoing screen remains tappable on top of it.
4627 CraftChrome.shared.updateTabAccessory(nil)
4650 // Release only this screen's accessory. A retained or prepared screen
4651 // can disappear after the destination has already published its own.
4652 CraftChrome.shared.clearTabAccessory(ownedBy: self)
46284653 // Popped (or its stack dismissed) while the bar was away: it comes
46294654 // back for whatever shows next, a web page included.
46304655 if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
@@ -4669,7 +4694,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
46694694 private func restoreShellChrome() {
46704695 guard !isAppScript else { return }
46714696 CraftChrome.shared.setHiddenByScreen(tabBarHidden)
4672 CraftChrome.shared.updateTabAccessory(tabAccessory)
4697 CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
46734698 }
46744699
46754700 /// The screen came on screen or went off it: a screen pushed over it was
@@ -5340,7 +5365,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
53405365 if options.keys.contains("tabAccessory") {
53415366 tabAccessory = options["tabAccessory"] as? [String: Any]
53425367 if navigationController?.topViewController === self, view.window != nil {
5343 CraftChrome.shared.updateTabAccessory(tabAccessory)
5368 CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
53445369 }
53455370 }
53465371 if options.keys.contains("title") {
@@ -6865,7 +6890,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
68656890
68666891 private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
68676892 if let authored = authoredKey(node, props: props) { return authored }
6868 return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
6893 // Repeated test selectors are valid for list rows. Retain each row by
6894 // its unique protocol ID before considering legacy selector identity.
6895 return [node["id"], props["testID"]]
6896 .compactMap { $0 as? String }
6897 .first { !$0.isEmpty }
68696898 }
68706899
68716900 /// The key a node's author gave it. A wrapper of one child (a
@@ -6877,7 +6906,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
68776906 private func authoredKey(_ node: [String: Any], props: [String: Any]) -> String? {
68786907 // The current @stacksjs/stx native compiler emits key in props; accept the IR
68796908 // field too so keyed children keep working when the compiler adopts it.
6880 let direct = [node["key"], props["key"], props["testID"]]
6909 let direct = [node["key"], props["key"]]
68816910 .compactMap { $0 as? String }
68826911 .first { !$0.isEmpty }
68836912 if let direct { return direct }
packages/ios/templates/testing/DriverTests.swift.templatemodified+5-1
Changes to packages/ios/templates/testing/DriverTests.swift.template
@@ -448,7 +448,11 @@ final class DriverTests: XCTestCase {
448448 var found = element(containing: arg)
449449 var swipes = 0
450450 while (found == nil || !(found!.isHittable)) && swipes < 8 {
451 app.swipeUp()
451 if let target = found, !target.frame.isEmpty, target.frame.midY < app.frame.midY {
452 app.swipeDown()
453 } else {
454 app.swipeUp()
455 }
452456 swipes += 1
453457 found = element(containing: arg)
454458 }
packages/typescript/src/__tests__/android-emulator.test.tsmodified+23-1
Changes to packages/typescript/src/__tests__/android-emulator.test.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
55import { createAndroidEmulator } from '../../../android/src/emulator'
66import type { AndroidEmulatorOptions } from '../../../android/src/emulator'
77
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false): { root: string, options: AndroidEmulatorOptions } {
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false): { root: string, options: AndroidEmulatorOptions } {
99 const root = mkdtempSync(join(tmpdir(), 'craft emulator fixture '))
1010 const sdk = join(root, 'managed sdk')
1111 for (const directory of ['bin', 'platform-tools', 'emulator']) mkdirSync(join(sdk, directory), { recursive: true })
@@ -30,6 +30,13 @@ else
3030 printf '1\\n' ;;
3131 *'pm path android') printf 'package:/system/framework/framework-res.apk\\n' ;;
3232 *'pidof com.android.systemui') printf '1234\\n' ;;
33 *'cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME')
34 if [ '${delayedHome ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/home-ready" ]; then
35 touch "$ANDROID_HOME/home-ready"
36 printf 'com.android.sdksetup/.DefaultActivity\\n'
37 else
38 printf 'com.android.launcher3/.Launcher\\n'
39 fi ;;
3340 *'logcat -b events -d -t 1')
3441 if [ '${delayedEvents ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/events-ready" ]; then
3542 touch "$ANDROID_HOME/events-ready"
@@ -113,6 +120,21 @@ test('boot and System UI do not admit a device before its event log is readable'
113120 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
114121}, 10_000)
115122
123test('boot completion waits for the automatic SDK setup activity to release HOME', async () => {
124 const { root, options } = fixture(true, false, false, true)
125 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
126 try {
127 session = await createAndroidEmulator(options)
128 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
129 const home = trace.filter(entry => entry.args.includes('resolve-activity'))
130 expect(home.map(entry => entry.stdout.trim())).toEqual(['com.android.sdksetup/.DefaultActivity', 'com.android.launcher3/.Launcher'])
131 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
132 expect(Date.parse(home[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt))
133 expect(identity.bootTimeoutMs).toBe(600_000)
134 }
135 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
136}, 10_000)
137
116138test('the SDK process fixture records hardware mode and reaps only its owned process', async () => {
117139 const { root, options } = fixture(true)
118140 options.bootTimeoutMs = 42_000
scripts/native-renderer-contract.test.tsmodified+3-2
Changes to scripts/native-renderer-contract.test.ts
@@ -2924,7 +2924,7 @@ describe('native renderer component contract', () => {
29242924 expect(iosNavigationTest).toContain('craft-tab-accessory')
29252925 expect(androidNavigationTest).toContain('pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack')
29262926 expect(androidNavigationTest).toContain('Open capabilities')
2927 expect(ios).toContain('CraftChrome.shared.updateTabAccessory(nil)')
2927 expect(ios).toContain('CraftChrome.shared.clearTabAccessory(ownedBy: self)')
29282928 expect(androidShell).toContain('accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) }')
29292929 expect(guide).toContain('`tabAccessory` adds one destination button beside the tab pill')
29302930 expect(iosReadme).toContain('`tabAccessory` destination beside the tab pill')
@@ -3003,7 +3003,8 @@ describe('native renderer component contract', () => {
30033003 })
30043004
30053005 it('prefers authored keys over compiler ids when reconciling native rows', () => {
3006 expect(ios).toContain('let direct = [node["key"], props["key"], props["testID"]]')
3006 expect(ios).toContain('let direct = [node["key"], props["key"]]')
3007 expect(ios).toContain('[node["id"], props["testID"]]')
30073008 // Through a lone wrapped child (a component's wrapper) only.
30083009 expect(ios).toContain('return authoredKey(only, props: only["props"] as? [String: Any] ?? [:])')
30093010 expect(iosFlatList).toContain('[node["key"], props["key"], props["testID"], node["id"]]')
scripts/release-workflow.test.tsmodified+4-4
Changes to scripts/release-workflow.test.ts
@@ -18,7 +18,7 @@ type Job = {
1818}
1919const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> }
2020const artifactDownload = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'
21const stxCompilerCommit = '3dd3240fc9a64f360e9690eee50c483be3325705'
21const stxCompilerCommit = '0d85afbab4b569ac2f09113a6c9a5d11d926145f'
2222const needs = (job: Job) => typeof job.needs === 'string' ? [job.needs] : job.needs ?? []
2323const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ?? ''
2424
@@ -101,8 +101,8 @@ test('workflow setup uses a known Pantry CLI instead of resolving latest', () =>
101101 for (const [name, job] of Object.entries(workflow.jobs)) {
102102 for (const step of job.steps ?? []) {
103103 if (step.uses?.startsWith('pantry-pm/pantry/packages/action@')) {
104 expect(step.with?.version, `${file} ${name} must pin Pantry CLI`).toBe('0.11.93')
105 expect(step.uses, `${file} ${name} must pin the matching action source`).toBe('pantry-pm/pantry/packages/action@e4b2d439c174c41d3e1c99b5743acb5789e92231')
104 expect(step.with?.version, `${file} ${name} must pin Pantry CLI`).toBe('0.11.94')
105 expect(step.uses, `${file} ${name} must pin the matching action source`).toBe('pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4')
106106 }
107107 }
108108 }
@@ -250,7 +250,7 @@ test('a failed native leg cannot publish a partial GitHub, Zig, or npm release',
250250 expect(release.jobs.npm.if).toContain("needs.prepare-npm.result == 'success'")
251251 expect(release.jobs.npm.if).toContain("needs.publish-release.result == 'success'")
252252 const zigPublish = registry.steps!.find(step => step.name === 'Publish Zig package')
253 expect(zigPublish?.with).toMatchObject({ version: '0.11.93', install: 'false', publish: 'zig', 'package-dir': 'packages/zig' })
253 expect(zigPublish?.with).toMatchObject({ version: '0.11.94', install: 'false', publish: 'zig', 'package-dir': 'packages/zig' })
254254 expect(zigPublish?.with?.release).toBeUndefined()
255255 expect(Object.entries(release.jobs).flatMap(([name, job]) =>
256256 (job.steps ?? []).filter(step => step.with?.publish === 'zig').map(() => name))).toEqual(['publish-pantry'])