ReviewOS

also looking at this

stacks/stx

fix(native): preserve authored keys in mutation nodes

#2084
Closed chris wants to merge codex/native-mutation-authored-keys into main
3 files +77 -3
packages/stx/src/native/bridge/protocol.tsmodified+3-1
Changes to packages/stx/src/native/bridge/protocol.ts
@@ -112,6 +112,8 @@ export interface RenderPayload {
112112
113113export interface NativeMutationNode {
114114 type: string
115 /** Authored reconciliation identity, retained across native mutations. */
116 key?: string
115117 props?: Record<string, unknown>
116118 style?: Partial<import('../compiler/ir').STXStyle>
117119 events?: Record<string, string>
@@ -121,7 +123,7 @@ export interface NativeMutationNode {
121123
122124export type NativeMutationOperation
123125 = | { op: 'createNode', id: string, node: NativeMutationNode, root?: boolean }
124 | { op: 'updateNode', id: string, patch: Omit<NativeMutationNode, 'type'> }
126 | { op: 'updateNode', id: string, patch: Omit<NativeMutationNode, 'type' | 'key'> }
125127 | { op: 'insertChild', parentId: string, childId: string, index: number }
126128 | { op: 'moveChild', parentId: string, childId: string, index: number }
127129 | { op: 'removeNode', id: string }
packages/stx/src/native/runtime/native-host.tsmodified+6-2
Changes to packages/stx/src/native/runtime/native-host.ts
@@ -52,6 +52,7 @@ export interface NativeNode {
5252
5353interface NodeRecord {
5454 type: string
55 key?: string
5556 props: Record<string, unknown>
5657 style: Record<string, unknown>
5758 baseStyle: Record<string, unknown>
@@ -131,7 +132,7 @@ export interface NativeHost {
131132 descendants: (node: NativeNode) => NativeNode[]
132133 scopesIn: (node: NativeNode) => string[]
133134 /** Register a node the native side already created, so the host can track it. */
134 adopt: (node: NativeNode, type: string, owner?: NativeNode | null, descriptor?: Pick<STXNode, 'props' | 'style' | 'events' | 'children' | '_classes' | '_inlineStyle'>) => NativeNode
135 adopt: (node: NativeNode, type: string, owner?: NativeNode | null, descriptor?: Pick<STXNode, 'key' | 'props' | 'style' | 'events' | 'children' | '_classes' | '_inlineStyle'>) => NativeNode
135136 /** Send whatever is pending now, rather than waiting for the scheduled flush. */
136137 flush: () => void
137138 /** Dispatch an event the native side reported, by the id handed to it. */
@@ -291,6 +292,7 @@ export function createNativeHost(options: NativeHostOptions): NativeHost {
291292 id: node.__stxId,
292293 node: {
293294 type: record.type,
295 ...(record.key === undefined ? {} : { key: record.key }),
294296 props: { ...record.props },
295297 style: { ...record.style },
296298 ...(Object.keys(record.events).length ? { events: { ...record.events } } : {}),
@@ -408,7 +410,7 @@ export function createNativeHost(options: NativeHostOptions): NativeHost {
408410 }
409411 }
410412
411 function adopt(node: NativeNode, type: string, owner?: NativeNode | null, descriptor?: Pick<STXNode, 'props' | 'style' | 'events' | 'children' | '_classes' | '_inlineStyle'>): NativeNode {
413 function adopt(node: NativeNode, type: string, owner?: NativeNode | null, descriptor?: Pick<STXNode, 'key' | 'props' | 'style' | 'events' | 'children' | '_classes' | '_inlineStyle'>): NativeNode {
412414 const suffix = /(?:^|\D)(\d+)$/.exec(node.__stxId)?.[1]
413415 if (suffix)
414416 counter = Math.max(counter, Number(suffix))
@@ -417,6 +419,7 @@ export function createNativeHost(options: NativeHostOptions): NativeHost {
417419 const baseStyle = descriptor?._inlineStyle ? { ...descriptor._inlineStyle } : Object.fromEntries(Object.entries(descriptor?.style ?? {}).filter(([key, value]) => classStyle[key] !== value))
418420 const record: NodeRecord = {
419421 type,
422 key: descriptor?.key,
420423 props: { ...(descriptor?.props ?? {}) },
421424 style: { ...(descriptor?.style ?? {}) },
422425 baseStyle,
@@ -660,6 +663,7 @@ export function createNativeHost(options: NativeHostOptions): NativeHost {
660663 }
661664 records.set(copy.__stxId, {
662665 type: descriptor.type,
666 key: descriptor.key,
663667 props: { ...descriptor.props, __stxId: copy.__stxId },
664668 style: { ...descriptor.style },
665669 baseStyle: { ...descriptor.baseStyle },
packages/stx/test/native/native-authored-keys.test.tsadded+68-0
Changes to packages/stx/test/native/native-authored-keys.test.ts
@@ -0,0 +1,68 @@
1import { describe, expect, it } from 'bun:test'
2import path from 'node:path'
3import vm from 'node:vm'
4import { compileSharedScreenBundle } from '../../src/native/compiler/bundle'
5import { createNode } from '../../src/native/compiler/ir'
6import { createNativeHost } from '../../src/native/runtime/native-host'
7import { createTempDir } from '../../src/temp-dir'
8
9describe('authored native reconciliation keys', () => {
10 it('preserves the initial key when a production conditional creates its replacement', async () => {
11 const root = createTempDir('stx-native-authored-keys-')
12 const file = path.join(root, 'Keys.stx')
13 await Bun.write(file, `<script>
14const safe = state(false)
15globalThis.toggleKeyProbe = () => safe.set(!safe())
16</script>
17<View>
18 <View :if="!safe()" key="retained-container" testID="key-probe"><Text>Plain</Text></View>
19 <SafeAreaView :if="safe()" key="retained-container" testID="key-probe"><Text>Safe</Text></SafeAreaView>
20</View>`)
21 const { code } = await compileSharedScreenBundle(file, { minify: true })
22 const messages: any[] = []
23 const scope: Record<string, any> = {
24 console, Promise, Date, Map, Set, WeakMap, WeakSet,
25 __stxNativeBridge: { platform: 'android', capabilityProtocolVersion: 1, capabilities: [],
26 postMessage: (raw: string) => messages.push(JSON.parse(raw)), onMessage() {} },
27 }
28 vm.runInNewContext(code, scope)
29 const settle = async () => { for (let i = 0; i < 6; i++) await Bun.sleep(0) }
30 await settle()
31 function find(node: any): any {
32 if (node?.props?.testID === 'key-probe') return node
33 for (const child of node?.children ?? []) {
34 const found = find(child)
35 if (found) return found
36 }
37 }
38 expect(find(messages.find(message => message.type === 'RENDER')?.payload.document)?.key).toBe('retained-container')
39 for (const type of ['SafeAreaView', 'View', 'SafeAreaView']) {
40 const before = messages.length
41 scope.toggleKeyProbe()
42 await settle()
43 const created = messages.slice(before).flatMap(message => message.payload?.operations ?? [])
44 .find(operation => operation.op === 'createNode' && operation.node?.props?.testID === 'key-probe')
45 expect(created?.node.type).toBe(type)
46 expect(created?.node.key).toBe('retained-container')
47 }
48 })
49
50 it('retains authored keys through cloning, removal and rematerialization', () => {
51 const operations: any[] = []
52 const host = createNativeHost({ send: batch => operations.push(...batch), schedule() {} })
53 const parent = host.adopt({ __stxId: 'root' }, 'View')
54 const descriptor = createNode('TextInput')
55 descriptor.key = 'draft-key'
56 const original = host.adopt({ __stxId: 'original' }, 'TextInput', undefined, descriptor)
57 const cloned = host.clone(original)
58 host.insert(parent, cloned)
59 host.flush()
60 expect(operations.find(operation => operation.op === 'createNode' && operation.id === cloned.__stxId)?.node.key).toBe('draft-key')
61 host.remove(cloned)
62 host.flush()
63 operations.length = 0
64 host.insert(parent, cloned)
65 host.flush()
66 expect(operations.find(operation => operation.op === 'createNode' && operation.id === cloned.__stxId)?.node.key).toBe('draft-key')
67 })
68})