diff --git a/.changeset/empty-et-ref-adapter.md b/.changeset/empty-et-ref-adapter.md new file mode 100644 index 0000000000..9173a50969 --- /dev/null +++ b/.changeset/empty-et-ref-adapter.md @@ -0,0 +1,5 @@ +--- + +--- + +No package release is required because this change only updates the unpublished Element Template transform/runtime internal ref adapter path and its internal tests, without changing public APIs, package exports, or release-facing defaults. diff --git a/packages/react/runtime/__test__/core/ref.test.ts b/packages/react/runtime/__test__/core/ref.test.ts new file mode 100644 index 0000000000..7819b41fed --- /dev/null +++ b/packages/react/runtime/__test__/core/ref.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { OrdinaryRefEffectQueue, SelectorRefProxy, applyOrdinaryRef, normalizeRefValue } from '../../src/core/ref.js'; +import type { RefProxyForwardedMethods } from '../../src/core/ref.js'; + +class TestSelectorRefProxy extends SelectorRefProxy { + constructor( + private readonly selectorValue: string, + private readonly schedule: (task: () => void) => void, + ) { + super(); + + return this.createProxy(); + } + + protected createProxyTarget(): TestSelectorRefProxy { + return new TestSelectorRefProxy(this.selectorValue, this.schedule); + } + + protected runOrDelay(task: () => void): void { + this.schedule(task); + } + + get selector(): string { + return this.selectorValue; + } +} + +interface TestSelectorRefProxy extends RefProxyForwardedMethods {} + +function stubReportError(): ReturnType { + const reportError = vi.fn(); + vi.stubGlobal('lynx', { ...(globalThis.lynx ?? {}), reportError }); + return reportError; +} + +describe('core/ref ordinary ref semantics', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('normalizes valid refs and empty refs', () => { + const callback = vi.fn(); + const objectRef = { current: null }; + + expect(normalizeRefValue(callback)).toBe(callback); + expect(normalizeRefValue(objectRef)).toBe(objectRef); + expect(normalizeRefValue(null)).toBeNull(); + expect(normalizeRefValue(undefined)).toBeUndefined(); + }); + + it('rejects invalid refs with the ReactLynx ordinary ref error', () => { + const error = 'Elements\' "ref" property should be a function, or an object created by createRef()'; + + expect(() => normalizeRefValue(false)).toThrowError(error); + expect(() => normalizeRefValue(1)).toThrowError(error); + expect(() => normalizeRefValue('ref')).toThrowError(error); + expect(() => normalizeRefValue({})).toThrowError(error); + }); + + it('assigns object refs', () => { + const ref = { current: null as string | null }; + const reportError = stubReportError(); + + applyOrdinaryRef(ref, 'node'); + expect(ref.current).toBe('node'); + + applyOrdinaryRef(ref, null); + expect(ref.current).toBeNull(); + expect(reportError).not.toHaveBeenCalled(); + }); + + it('runs function cleanup instead of calling null when cleanup exists', () => { + const cleanup = vi.fn(); + const ref = vi.fn(() => cleanup); + const reportError = stubReportError(); + + applyOrdinaryRef(ref, 'node'); + expect(ref._unmount).toBe(cleanup); + ref.mockClear(); + + applyOrdinaryRef(ref, null); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(ref).not.toHaveBeenCalled(); + expect(ref._unmount).toBeUndefined(); + expect(reportError).not.toHaveBeenCalled(); + }); + + it('calls function refs with null when no cleanup exists', () => { + const ref = vi.fn(); + const reportError = stubReportError(); + + applyOrdinaryRef(ref, 'node'); + ref.mockClear(); + + applyOrdinaryRef(ref, null); + + expect(ref).toHaveBeenCalledWith(null); + expect(reportError).not.toHaveBeenCalled(); + }); + + it('ignores non-function cleanup return values', () => { + const refMock = vi.fn(() => null); + const ref = refMock as unknown as ((value: string | null) => void) & { + _unmount?: (() => void) | void; + }; + const reportError = stubReportError(); + + applyOrdinaryRef(ref, 'node'); + refMock.mockClear(); + + applyOrdinaryRef(ref, null); + + expect(refMock).toHaveBeenCalledWith(null); + expect(ref._unmount).toBeUndefined(); + expect(reportError).not.toHaveBeenCalled(); + }); + + it('reports ref errors without throwing', () => { + const error = new Error('ref failed'); + const ref = vi.fn(() => { + throw error; + }); + const reportError = stubReportError(); + + applyOrdinaryRef(ref, 'node'); + + expect(reportError).toHaveBeenCalledWith(error); + }); + + it('queues ordinary ref effects as detach before attach', () => { + const queue = new OrdinaryRefEffectQueue(); + const calls: Array<[label: string, value: string | null]> = []; + const oldRef = vi.fn((value: string | null) => { + calls.push(['old', value]); + }); + const newRef = vi.fn((value: string | null) => { + calls.push(['new', value]); + }); + const unchangedRef = vi.fn(); + const reportError = stubReportError(); + + queue.queue(unchangedRef, unchangedRef, 'ignored'); + queue.queue(oldRef, newRef, 'node'); + expect(queue.hasPending()).toBe(true); + + queue.flush(token => `proxy:${token}`); + + expect(calls).toEqual([ + ['old', null], + ['new', 'proxy:node'], + ]); + expect(unchangedRef).not.toHaveBeenCalled(); + expect(queue.hasPending()).toBe(false); + expect(reportError).not.toHaveBeenCalled(); + }); + + it('forwards NodesRef methods through backend-provided selector and scheduler', () => { + const exec = vi.fn(); + const fields = vi.fn(() => ({ exec })); + const select = vi.fn(() => ({ fields })); + const createSelectorQuery = vi.fn(() => ({ select })); + const originalLynx = globalThis.lynx; + const tasks: (() => void)[] = []; + vi.stubGlobal('lynx', { createSelectorQuery }); + + try { + new TestSelectorRefProxy('[ref=test]', task => tasks.push(task)).fields({ id: true }).exec(); + + expect(exec).not.toHaveBeenCalled(); + expect(tasks).toHaveLength(1); + + tasks[0]!(); + + expect(createSelectorQuery).toHaveBeenCalledTimes(1); + expect(select).toHaveBeenCalledWith('[ref=test]'); + expect(fields).toHaveBeenCalledWith({ id: true }); + expect(exec).toHaveBeenCalledTimes(1); + } finally { + vi.stubGlobal('lynx', originalLynx); + } + }); +}); diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/does-not-emit-patch-when-attrs-reference-reused/case.ts b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/does-not-emit-patch-when-attrs-reference-reused/case.ts index f48dd8caf5..c7192c8049 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/does-not-emit-patch-when-attrs-reference-reused/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/does-not-emit-patch-when-attrs-reference-reused/case.ts @@ -12,7 +12,7 @@ export function run() { const props = { a: 1 }; instance.setAttribute('attributeSlots', [props]); markElementTemplateHydrated(); - instance.markCreateEmittedForHydration(); + instance.markMaterializedByHydration(); resetGlobalCommitContext(); instance.setAttribute('attributeSlots', [props]); diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-non-object-attrs-entry-as-empty-object/case.ts b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-non-object-attrs-entry-as-empty-object/case.ts index 6c8f088c1a..b1e2eaa2b4 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-non-object-attrs-entry-as-empty-object/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-non-object-attrs-entry-as-empty-object/case.ts @@ -11,7 +11,7 @@ export function run() { const instance = new BackgroundElementTemplateInstance('view'); instance.setAttribute('attributeSlots', [{ a: 1 }]); markElementTemplateHydrated(); - instance.markCreateEmittedForHydration(); + instance.markMaterializedByHydration(); resetGlobalCommitContext(); instance.setAttribute('attributeSlots', [null]); diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-nullish-attrs-as-empty-object/case.ts b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-nullish-attrs-as-empty-object/case.ts index c0f0a75d5c..6c7fae0d4f 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-nullish-attrs-as-empty-object/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/treats-nullish-attrs-as-empty-object/case.ts @@ -11,7 +11,7 @@ export function run() { const instance = new BackgroundElementTemplateInstance('view'); instance.setAttribute('attributeSlots', [{ a: 1 }]); markElementTemplateHydrated(); - instance.markCreateEmittedForHydration(); + instance.markMaterializedByHydration(); resetGlobalCommitContext(); instance.setAttribute('attributeSlots', []); diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/ref/direct-ref/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/background/ref/direct-ref/index.tsx new file mode 100644 index 0000000000..9254a7ecb2 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/background/ref/direct-ref/index.tsx @@ -0,0 +1,7 @@ +interface AppProps { + hostRef?: unknown; +} + +export function App({ hostRef }: AppProps) { + return direct; +} diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/ref/multi-ref/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/background/ref/multi-ref/index.tsx new file mode 100644 index 0000000000..57beaa4bef --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/background/ref/multi-ref/index.tsx @@ -0,0 +1,20 @@ +interface SpreadProps { + id?: string; + ref?: unknown; +} + +interface AppProps { + directRef?: unknown; + objectRef?: unknown; + spread?: SpreadProps; +} + +export function App({ directRef, objectRef, spread = {} }: AppProps) { + return ( + + direct + object + spread + + ); +} diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/ref/spread-ref/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/background/ref/spread-ref/index.tsx new file mode 100644 index 0000000000..3e28593825 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/background/ref/spread-ref/index.tsx @@ -0,0 +1,14 @@ +interface SpreadProps { + id?: string; + ref?: unknown; + 'main-thread:ref'?: unknown; + 'worklet:ref'?: unknown; +} + +interface AppProps { + spread?: SpreadProps; +} + +export function App({ spread = {} }: AppProps) { + return spread; +} diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/ref/unsupported-ref/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/background/ref/unsupported-ref/index.tsx new file mode 100644 index 0000000000..104ef49b88 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/background/ref/unsupported-ref/index.tsx @@ -0,0 +1,12 @@ +interface AppProps { + mainThreadRef?: unknown; + workletRef?: unknown; +} + +export function App({ mainThreadRef, workletRef }: AppProps) { + return ( + + unsupported + + ); +} diff --git a/packages/react/runtime/__test__/element-template/internal/legacy-internal-guardrail.test.ts b/packages/react/runtime/__test__/element-template/internal/legacy-internal-guardrail.test.ts index 090457877e..aa530b7ed4 100644 --- a/packages/react/runtime/__test__/element-template/internal/legacy-internal-guardrail.test.ts +++ b/packages/react/runtime/__test__/element-template/internal/legacy-internal-guardrail.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import * as ElementTemplateRuntime from '../../../src/element-template/index.js'; +import * as ElementTemplateInternal from '../../../src/element-template/internal.js'; import { SnapshotInstance } from '../../../src/element-template/internal.js'; describe('legacy internal guardrail', () => { @@ -8,4 +10,9 @@ describe('legacy internal guardrail', () => { 'SnapshotInstance should not be instantiated when using Element Template.', ); }); + + it('keeps ref attr slot adapter on the ET internal surface only', () => { + expect(ElementTemplateInternal.adaptRefAttrSlot).toBeTypeOf('function'); + expect('adaptRefAttrSlot' in ElementTemplateRuntime).toBe(false); + }); }); diff --git a/packages/react/runtime/__test__/element-template/native/callDestroyLifetimeFun.test.ts b/packages/react/runtime/__test__/element-template/native/callDestroyLifetimeFun.test.ts index 486c9c1b67..9e5a86cca5 100644 --- a/packages/react/runtime/__test__/element-template/native/callDestroyLifetimeFun.test.ts +++ b/packages/react/runtime/__test__/element-template/native/callDestroyLifetimeFun.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { globalCommitContext, - markRemovedSubtreeForCurrentCommit, + markRemovedSubtreeForPostDispatchTeardown, } from '../../../src/element-template/background/commit-context.js'; import { resetElementTemplateCommitState } from '../../../src/element-template/background/commit-hook.js'; import { @@ -37,7 +37,7 @@ describe('callDestroyLifetimeFun', () => { const root = new BackgroundElementTemplateInstance('_et_test'); const child = new BackgroundElementTemplateInstance('_et_child'); root.appendChild(child); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); globalCommitContext.ops = [ ElementTemplateUpdateOps.createTemplate, child.instanceId, @@ -49,7 +49,7 @@ describe('callDestroyLifetimeFun', () => { callDestroyLifetimeFun(); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(globalCommitContext.ops).toEqual([]); expect(backgroundElementTemplateInstanceManager.values.size).toBe(0); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts index 8ec75336ec..3e37c57012 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts @@ -6,9 +6,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { globalCommitContext, - markRemovedSubtreeForCurrentCommit, + markRemovedSubtreeForPostDispatchTeardown, resetGlobalCommitContext, - takeRemovedSubtreesForCurrentCommit, + takeRemovedSubtreesForPostDispatchTeardown, } from '../../../../src/element-template/background/commit-context.js'; import { BackgroundElementTemplateInstance, @@ -28,32 +28,32 @@ describe('ElementTemplate commit context', () => { it('keeps removed subtree roots outside the update payload', () => { const root = new BackgroundElementTemplateInstance('view'); - markRemovedSubtreeForCurrentCommit(root); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); + markRemovedSubtreeForPostDispatchTeardown(root); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([root]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([root]); expect({ ops: globalCommitContext.ops, flushOptions: globalCommitContext.flushOptions, flowIds: globalCommitContext.flowIds, - }).not.toHaveProperty('removedSubtrees'); + }).not.toHaveProperty('removedSubtreesAwaitingTeardown'); }); it('takes removed subtree roots from the current commit once', () => { const root = new BackgroundElementTemplateInstance('view'); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); - expect(takeRemovedSubtreesForCurrentCommit()).toEqual([root]); - expect(takeRemovedSubtreesForCurrentCommit()).toEqual([]); + expect(takeRemovedSubtreesForPostDispatchTeardown()).toEqual([root]); + expect(takeRemovedSubtreesForPostDispatchTeardown()).toEqual([]); }); it('clears non-payload state when the global commit context resets', () => { const root = new BackgroundElementTemplateInstance('view'); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); resetGlobalCommitContext(); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); }); it('collects only handles that are registered in the main-thread registry', () => { diff --git a/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts index 9536871b09..fd2f207314 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts @@ -18,12 +18,13 @@ import { BackgroundElementTemplateInstance } from '../../../../src/element-templ import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; import { globalCommitContext, - markRemovedSubtreeForCurrentCommit, + markRemovedSubtreeForPostDispatchTeardown, } from '../../../../src/element-template/background/commit-context.js'; import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; import { PipelineOrigins } from '../../../../src/element-template/lynx/performance.js'; import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; +import { clearRefState, queueRefAttrUpdate } from '../../../../src/element-template/prop-adapters/ref.js'; function createRawTextOps(id: number, text: string) { return [ @@ -48,6 +49,7 @@ describe('ElementTemplate commit hook', () => { resetElementTemplateCommitState(); backgroundElementTemplateInstanceManager.clear(); backgroundElementTemplateInstanceManager.nextId = 0; + clearRefState(); updateEvents = []; envManager.resetEnv('background'); installElementTemplateCommitHook(); @@ -64,6 +66,7 @@ describe('ElementTemplate commit hook', () => { envManager.switchToBackground(); resetElementTemplateHydrationListener(); resetElementTemplateCommitState(); + clearRefState(); }); it('dispatches update after commit when hydrated', () => { @@ -167,11 +170,11 @@ describe('ElementTemplate commit hook', () => { try { markElementTemplateHydrated(); const root = new BackgroundElementTemplateInstance('root'); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); globalCommitContext.ops = createRawTextOps(1, 'flush'); options.__c?.({} as unknown as object, []); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); vi.advanceTimersByTime(9999); expect(backgroundElementTemplateInstanceManager.get(root.instanceId)).toBe(root); @@ -183,13 +186,42 @@ describe('ElementTemplate commit hook', () => { } }); + it('flushes ref-only updates without dispatching native ops', () => { + const ref = vi.fn(); + markElementTemplateHydrated(); + queueRefAttrUpdate(null, ref, -2, 0); + + options.__c?.({} as unknown as object, []); + + envManager.switchToMainThread(); + expect(updateEvents).toHaveLength(0); + envManager.switchToBackground(); + expect(ref).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + }); + + it('flushes pre-hydration ref effects on commit without dispatching native ops', () => { + const ref = vi.fn(); + queueRefAttrUpdate(null, ref, 1, 0); + + options.__c?.({} as unknown as object, []); + + envManager.switchToMainThread(); + expect(updateEvents).toHaveLength(0); + envManager.switchToBackground(); + expect(ref).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=1-0]', + })); + }); + it('keeps pending removed subtrees when only the hydration listener is reset', () => { const root = new BackgroundElementTemplateInstance('root'); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); resetElementTemplateHydrationListener(); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([root]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([root]); }); it('cancels scheduled removed subtree cleanup on background destroy', () => { @@ -219,12 +251,12 @@ describe('ElementTemplate commit hook', () => { try { markElementTemplateHydrated(); const root = new BackgroundElementTemplateInstance('root'); - markRemovedSubtreeForCurrentCommit(root); + markRemovedSubtreeForPostDispatchTeardown(root); globalCommitContext.ops = createRawTextOps(1, 'flush'); expect(() => options.__c?.({} as unknown as object, [])).toThrow(dispatchError); expect(globalCommitContext.ops).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); vi.advanceTimersByTime(10000); expect(backgroundElementTemplateInstanceManager.get(root.instanceId)).toBeUndefined(); @@ -234,6 +266,29 @@ describe('ElementTemplate commit hook', () => { } }); + it('clears pending refs when update dispatch throws', () => { + const ref = vi.fn(); + const dispatchError = new Error('update dispatch failed'); + const dispatchSpy = vi.spyOn(lynx.getCoreContext(), 'dispatchEvent').mockImplementationOnce(() => { + throw dispatchError; + }); + + try { + markElementTemplateHydrated(); + queueRefAttrUpdate(null, ref, -2, 0); + globalCommitContext.ops = createRawTextOps(1, 'flush'); + + expect(() => options.__c?.({} as unknown as object, [])).toThrow(dispatchError); + expect(ref).not.toHaveBeenCalled(); + + globalCommitContext.ops = []; + options.__c?.({} as unknown as object, []); + expect(ref).not.toHaveBeenCalled(); + } finally { + dispatchSpy.mockRestore(); + } + }); + it('is idempotent', () => { installElementTemplateCommitHook(); installElementTemplateCommitHook(); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts index 64b5c40e74..ec8a175bf5 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts @@ -13,6 +13,7 @@ import { } from '../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; import { clearEventState, getEventHandlerForEventValue } from '../../../../src/element-template/prop-adapters/event.js'; +import { clearRefState, flushPendingRefs } from '../../../../src/element-template/prop-adapters/ref.js'; import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; import type { SerializedElementTemplate } from '../../../../src/element-template/protocol/types.js'; import { @@ -62,6 +63,7 @@ describe('hydrate', () => { backgroundElementTemplateInstanceManager.nextId = 0; clearEtAttrPlanMap(); clearEventState(); + clearRefState(); resetElementTemplateCommitState(); vi.clearAllMocks(); (globalThis as { __LYNX_REPORT_ERROR_CALLS?: Error[] }).__LYNX_REPORT_ERROR_CALLS = []; @@ -205,7 +207,7 @@ describe('hydrate', () => { [101, 102, 103], ]); expect(root.elementSlots[0]).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(101)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(102)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(103)).toBeUndefined(); @@ -239,7 +241,7 @@ describe('hydrate', () => { [stale.instanceId], ]); expect(root.elementSlots[0]).toEqual([keep]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([stale]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([stale]); }); it('moves serialized children to match the background slot order', () => { @@ -274,7 +276,7 @@ describe('hydrate', () => { c.instanceId, ]); expect(root.elementSlots[0]).toEqual([b, a, c]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); }); it('treats a source-before-target cross-slot hydrate candidate as remove and recreate', () => { @@ -321,7 +323,7 @@ describe('hydrate', () => { ]); expect(root.elementSlots[0]).toEqual([]); expect(root.elementSlots[1]).toEqual([moved]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(mainThreadId)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(localId)).toBe(moved); }); @@ -373,7 +375,7 @@ describe('hydrate', () => { ]); expect(root.elementSlots[0]).toEqual([keep]); expect(root.elementSlots[1]).toEqual([moved]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(-2)).toBeUndefined(); }); @@ -421,7 +423,7 @@ describe('hydrate', () => { ]); expect(root.elementSlots[0]).toEqual([moved]); expect(root.elementSlots[1]).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(mainThreadId)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(localId)).toBe(moved); }); @@ -488,7 +490,7 @@ describe('hydrate', () => { ]); expect(root.elementSlots[0]).toEqual([]); expect(root.elementSlots[1]).toEqual([first, second]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(-2)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(-3)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(firstLocalId)).toBe(first); @@ -795,6 +797,45 @@ describe('hydrate', () => { (globalThis as { __LYNX_REPORT_ERROR_CALLS?: Error[] }).__LYNX_REPORT_ERROR_CALLS = []; }); + it('drops the hydrate stream when a matched child fails to hydrate', () => { + const oldReportError = lynx.reportError; + const reportError = vi.fn(); + lynx.reportError = reportError; + + try { + const root = new BackgroundElementTemplateInstance('root', ['after-root']); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + root.appendChild(slot); + const child = new BackgroundElementTemplateInstance('child'); + slot.appendChild(child); + const oldRootId = root.instanceId; + const oldChildId = child.instanceId; + + const stream = hydrate( + createHydrationTemplate(-1, 'root', { + attributeSlots: ['before-root'], + elementSlots: [[ + createHydrationChild(-1, 'child'), + ]], + }), + root, + ); + + expect(stream).toEqual([]); + expect(reportError).toHaveBeenCalledTimes(1); + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain( + 'invalid uid -1 for \'child\'', + ); + expect(backgroundElementTemplateInstanceManager.get(oldRootId)).toBeUndefined(); + expect(backgroundElementTemplateInstanceManager.get(-1)).toBe(root); + expect(backgroundElementTemplateInstanceManager.get(oldChildId)).toBe(child); + } finally { + lynx.reportError = oldReportError; + (globalThis as { __LYNX_REPORT_ERROR_CALLS?: Error[] }).__LYNX_REPORT_ERROR_CALLS = []; + } + }); + it('treats missing serialized slot arrays as empty', () => { const root = new BackgroundElementTemplateInstance('root'); @@ -1008,6 +1049,28 @@ describe('hydrate', () => { expect(getEventHandlerForEventValue('-12:0:bindtap')).toBeUndefined(); }); + it('prepares spread ref markers with the serialized uid without queueing hydrate ref callbacks', () => { + __etAttrPlanMap.root = [0, adaptSpreadAttrSlot]; + const root = new BackgroundElementTemplateInstance('root'); + const ref = vi.fn(); + root.setAttribute('attributeSlots', [{ ref }]); + flushPendingRefs(); + expect(ref).toHaveBeenCalledTimes(1); + ref.mockClear(); + + const stream = hydrate( + createHydrationTemplate(-13, 'root', { + attributeSlots: [{ ref: '-13-0' }], + }), + root, + ); + flushPendingRefs(); + + expect(stream).toEqual([]); + expect(root.attributeSlots).toEqual([{ ref: '-13-0' }]); + expect(ref).not.toHaveBeenCalled(); + }); + it('skips sparse background slot indexes when checking trailing slots', () => { const root = new BackgroundElementTemplateInstance('root'); const slot = new BackgroundElementTemplateSlot(); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts index 2b9681aac3..e584a8b2bf 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts @@ -21,9 +21,11 @@ import { ElementTemplateUpdateOps } from '../../../../src/element-template/proto import { __etAttrPlanMap, adaptEventAttrSlot, + adaptRefAttrSlot, adaptSpreadAttrSlot, clearEtAttrPlanMap, } from '../../../../src/element-template/runtime/template/attr-slot-plan.js'; +import { clearRefState, flushPendingRefs } from '../../../../src/element-template/prop-adapters/ref.js'; function createTextNode(text: string): BackgroundElementTemplateInstance { return new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, [text]); @@ -37,6 +39,7 @@ describe('BackgroundElementTemplateInstance', () => { backgroundElementTemplateInstanceManager.nextId = 0; clearEtAttrPlanMap(); clearEventState(); + clearRefState(); resetElementTemplateCommitState(); }); @@ -82,8 +85,8 @@ describe('BackgroundElementTemplateInstance', () => { expect(slot.childNodes).toEqual([child]); markElementTemplateHydrated(); - parent.markCreateEmittedForHydration(); - child.markCreateEmittedForHydration(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); globalCommitContext.ops = []; child.parentNode?.removeChild(child); @@ -207,6 +210,76 @@ describe('BackgroundElementTemplateInstance', () => { ]); }); + it('queues direct ref attach when inserting a post-hydration template', () => { + const ref = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + parent.emitCreate(); + + markElementTemplateHydrated(); + globalCommitContext.ops = []; + + const child = new BackgroundElementTemplateInstance('view'); + child.setAttribute('attributeSlots', [ref]); + slot.appendChild(child); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([ + 1, + child.instanceId, + 'view', + null, + [`${child.instanceId}-0`], + [], + 3, + parent.instanceId, + 0, + child.instanceId, + 0, + ]); + expect(ref).toHaveBeenCalledWith(expect.objectContaining({ + selector: `[ref=${child.instanceId}-0]`, + })); + }); + + it('does not re-attach stable direct refs when moving an existing hydrated child', () => { + const ref = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const before = new BackgroundElementTemplateInstance('view'); + const child = new BackgroundElementTemplateInstance('view'); + child.setAttribute('attributeSlots', [ref]); + slot.appendChild(before); + slot.appendChild(child); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + before.markMaterializedByHydration(); + child.markMaterializedByHydration(); + child.prepareAttributeSlotsForNative(); + flushPendingRefs(); + ref.mockClear(); + globalCommitContext.ops = []; + + slot.insertBefore(child, before); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([ + 3, + parent.instanceId, + 0, + child.instanceId, + before.instanceId, + ]); + expect(ref).not.toHaveBeenCalled(); + }); + it('defers nested slot inserts until the owner template is created', () => { const parent = new BackgroundElementTemplateInstance('view'); const slot = new BackgroundElementTemplateSlot(); @@ -439,9 +512,9 @@ describe('BackgroundElementTemplateInstance', () => { slot.appendChild(child); markElementTemplateHydrated(); - parent.markCreateEmittedForHydration(); - child.markCreateEmittedForHydration(); - grandchild.markCreateEmittedForHydration(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); + grandchild.markMaterializedByHydration(); globalCommitContext.ops = []; slot.removeChild(child); @@ -453,7 +526,176 @@ describe('BackgroundElementTemplateInstance', () => { child.instanceId, [child.instanceId, grandchild.instanceId], ]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([child]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([child]); + }); + + it('queues direct ref cleanup when removing a hydrated subtree', () => { + const cleanup = vi.fn(); + const ref = vi.fn(() => cleanup); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const child = new BackgroundElementTemplateInstance('view'); + slot.appendChild(child); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); + child.setAttribute('attributeSlots', [ref]); + flushPendingRefs(); + ref.mockClear(); + globalCommitContext.ops = []; + + slot.removeChild(child); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([ + 4, + parent.instanceId, + 0, + child.instanceId, + [child.instanceId], + ]); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(ref).not.toHaveBeenCalled(); + }); + + it('queues direct object ref cleanup when removing a hydrated subtree', () => { + const ref = { current: null }; + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const child = new BackgroundElementTemplateInstance('view'); + slot.appendChild(child); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); + child.setAttribute('attributeSlots', [ref]); + flushPendingRefs(); + expect(ref.current).toMatchObject({ selector: `[ref=${child.instanceId}-0]` }); + globalCommitContext.ops = []; + + slot.removeChild(child); + flushPendingRefs(); + + expect(ref.current).toBeNull(); + }); + + it('queues all direct and spread ref cleanups when removing a hydrated subtree', () => { + const directRef = vi.fn(); + const cleanup = vi.fn(); + const spreadRef = vi.fn(() => cleanup); + __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const child = new BackgroundElementTemplateInstance('view'); + slot.appendChild(child); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); + child.setAttribute('attributeSlots', [directRef, { ref: spreadRef }]); + flushPendingRefs(); + expect(directRef).toHaveBeenCalledTimes(1); + expect(spreadRef).toHaveBeenCalledTimes(1); + directRef.mockClear(); + spreadRef.mockClear(); + + slot.removeChild(child); + flushPendingRefs(); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(spreadRef).not.toHaveBeenCalled(); + expect(directRef).toHaveBeenCalledWith(null); + }); + + it('queues nested direct and spread ref cleanup when removing a hydrated subtree', () => { + const childCleanup = vi.fn(); + const childRef = vi.fn(() => childCleanup); + const directGrandchildRef = vi.fn(); + const grandchildCleanup = vi.fn(); + const grandchildSpreadRef = vi.fn(() => grandchildCleanup); + __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const child = new BackgroundElementTemplateInstance('view'); + const childSlot = new BackgroundElementTemplateSlot(); + childSlot.setAttribute('id', 0); + const grandchild = new BackgroundElementTemplateInstance('view'); + child.appendChild(childSlot); + childSlot.appendChild(grandchild); + slot.appendChild(child); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); + grandchild.markMaterializedByHydration(); + child.setAttribute('attributeSlots', [childRef]); + grandchild.setAttribute('attributeSlots', [ + directGrandchildRef, + { ref: grandchildSpreadRef }, + ]); + flushPendingRefs(); + expect(childRef).toHaveBeenCalledTimes(1); + expect(directGrandchildRef).toHaveBeenCalledTimes(1); + expect(grandchildSpreadRef).toHaveBeenCalledTimes(1); + childRef.mockClear(); + directGrandchildRef.mockClear(); + grandchildSpreadRef.mockClear(); + globalCommitContext.ops = []; + + slot.removeChild(child); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([ + 4, + parent.instanceId, + 0, + child.instanceId, + [child.instanceId, grandchild.instanceId], + ]); + expect(childCleanup).toHaveBeenCalledTimes(1); + expect(grandchildCleanup).toHaveBeenCalledTimes(1); + expect(childRef).not.toHaveBeenCalled(); + expect(grandchildSpreadRef).not.toHaveBeenCalled(); + expect(directGrandchildRef).toHaveBeenCalledWith(null); + }); + + it('does not repeat direct function ref cleanup for detached subtrees on destroy', () => { + const cleanup = vi.fn(); + const ref = vi.fn(() => cleanup); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const child = new BackgroundElementTemplateInstance('view'); + slot.appendChild(child); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + child.markMaterializedByHydration(); + child.setAttribute('attributeSlots', [ref]); + flushPendingRefs(); + expect(ref).toHaveBeenCalledTimes(1); + + slot.removeChild(child); + flushPendingRefs(); + expect(cleanup).toHaveBeenCalledTimes(1); + + destroyElementTemplateBackgroundRuntime(); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(ref).toHaveBeenCalledTimes(1); }); it('does not emit patches for pre-hydration slot mutations', () => { @@ -471,7 +713,32 @@ describe('BackgroundElementTemplateInstance', () => { expect(parent.elementSlots[0]).toEqual([]); expect(globalCommitContext.ops).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); + expect(backgroundElementTemplateInstanceManager.get(childId)).toBeUndefined(); + }); + + it('cleans pre-hydration direct refs when removing a slot child before hydrate', () => { + const ref = { current: null }; + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const parent = new BackgroundElementTemplateInstance('view'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const child = new BackgroundElementTemplateInstance('view'); + const childId = child.instanceId; + + slot.appendChild(child); + child.setAttribute('attributeSlots', [ref]); + flushPendingRefs(); + expect(ref.current).toMatchObject({ selector: `[ref=${child.instanceId}-0]` }); + + globalCommitContext.ops = []; + slot.removeChild(child); + flushPendingRefs(); + + expect(ref.current).toBeNull(); + expect(parent.elementSlots[0]).toEqual([]); + expect(globalCommitContext.ops).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(childId)).toBeUndefined(); }); @@ -488,7 +755,7 @@ describe('BackgroundElementTemplateInstance', () => { expect(parent.elementSlots[0]).toEqual([]); expect(globalCommitContext.ops).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); }); it('clears cached elementSlots when removing a slot child', () => { @@ -532,6 +799,25 @@ describe('BackgroundElementTemplateInstance', () => { expect(child.parent).toBeNull(); }); + it('clears direct object refs when removing from the root container', () => { + const ref = { current: null }; + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const root = new BackgroundElementTemplateInstance('root'); + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + root.appendChild(instance); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + instance.setAttribute('attributeSlots', [ref]); + flushPendingRefs(); + expect(ref.current).toMatchObject({ selector: '[ref=-2-0]' }); + + root.removeChild(instance); + flushPendingRefs(); + + expect(ref.current).toBeNull(); + }); + it('reports error for emitCreate with illegal handleId 0 in dev', () => { const lynxObj = globalThis.lynx as typeof lynx & { reportError?: (error: Error) => void }; const oldReportError = lynxObj.reportError; @@ -564,6 +850,185 @@ describe('BackgroundElementTemplateInstance', () => { ]); }); + it('queues direct ref attach when preparing hydrated attribute slots', () => { + const ref = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view', [ref]); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + + instance.prepareAttributeSlotsForNative(); + flushPendingRefs(); + + expect(instance.attributeSlots).toEqual(['-2-0']); + expect(ref).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + }); + + it('queues direct ref changes without emitting native ops when marker is unchanged', () => { + const oldRef = vi.fn(); + const newRef = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + + instance.setAttribute('attributeSlots', [oldRef]); + flushPendingRefs(); + oldRef.mockClear(); + globalCommitContext.ops = []; + + instance.setAttribute('attributeSlots', [newRef]); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([]); + expect(oldRef).toHaveBeenCalledWith(null); + expect(newRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + }); + + it('queues spread ref attach/update/detach from raw ref identity', () => { + const oldRef = vi.fn(); + const newRef = vi.fn(); + __etAttrPlanMap.view = [0, adaptSpreadAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + + instance.setAttribute('attributeSlots', [{ id: 'cta', ref: oldRef }]); + flushPendingRefs(); + expect(instance.attributeSlots).toEqual([{ id: 'cta', ref: '-2-0' }]); + expect(oldRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + oldRef.mockClear(); + globalCommitContext.ops = []; + + instance.setAttribute('attributeSlots', [{ id: 'cta-next', ref: oldRef }]); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([ + ElementTemplateUpdateOps.setAttribute, + -2, + 0, + { id: 'cta-next', ref: '-2-0' }, + ]); + expect(oldRef).not.toHaveBeenCalled(); + globalCommitContext.ops = []; + + instance.setAttribute('attributeSlots', [{ id: 'cta-next', ref: newRef }]); + flushPendingRefs(); + + expect(globalCommitContext.ops).toEqual([]); + expect(oldRef).toHaveBeenCalledWith(null); + expect(newRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + newRef.mockClear(); + + instance.setAttribute('attributeSlots', [{ id: 'cta-next' }]); + flushPendingRefs(); + + expect(newRef).toHaveBeenCalledWith(null); + }); + + it('queues direct and spread refs independently in descriptor order', () => { + const directRef = vi.fn(); + const spreadRef = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + + instance.setAttribute('attributeSlots', [directRef, { ref: spreadRef }]); + flushPendingRefs(); + + expect(instance.attributeSlots).toEqual(['-2-0', { ref: '-2-1' }]); + expect(directRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + expect(spreadRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-1]', + })); + + directRef.mockClear(); + spreadRef.mockClear(); + instance.setAttribute('attributeSlots', [directRef, {}]); + flushPendingRefs(); + + expect(spreadRef).toHaveBeenCalledWith(null); + expect(directRef).not.toHaveBeenCalled(); + }); + + it('does not let explicit undefined spread refs detach sibling direct refs', () => { + const directRef = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + + instance.setAttribute('attributeSlots', [directRef, { ref: undefined }]); + flushPendingRefs(); + + expect(instance.attributeSlots).toEqual(['-2-0', { ref: null }]); + expect(directRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + }); + + it('keeps a stable direct ref attached while spread ref presence changes', () => { + const ref = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + + instance.setAttribute('attributeSlots', [ref, {}]); + flushPendingRefs(); + expect(ref).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + ref.mockClear(); + + instance.setAttribute('attributeSlots', [ref, { ref: undefined }]); + flushPendingRefs(); + expect(ref).not.toHaveBeenCalled(); + ref.mockClear(); + + instance.setAttribute('attributeSlots', [ref, {}]); + flushPendingRefs(); + + expect(instance.attributeSlots).toEqual(['-2-0', {}]); + expect(ref).not.toHaveBeenCalled(); + }); + + it('queues spread and later direct refs independently', () => { + const spreadRef = vi.fn(); + const directRef = vi.fn(); + __etAttrPlanMap.view = [0, adaptSpreadAttrSlot, 1, adaptRefAttrSlot]; + const instance = new BackgroundElementTemplateInstance('view'); + backgroundElementTemplateInstanceManager.updateId(instance.instanceId, -2); + instance.markMaterializedByHydration(); + markElementTemplateHydrated(); + + instance.setAttribute('attributeSlots', [{ ref: spreadRef }, directRef]); + flushPendingRefs(); + + expect(instance.attributeSlots).toEqual([{ ref: '-2-0' }, '-2-1']); + expect(spreadRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + expect(directRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-1]', + })); + }); + it('ignores legacy create options metadata props', () => { const instance = new BackgroundElementTemplateInstance('view'); instance.setAttribute('options', { diff --git a/packages/react/runtime/__test__/element-template/runtime/background/ref/compiled-fixtures.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/ref/compiled-fixtures.test.tsx new file mode 100644 index 0000000000..16f8f6128a --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/background/ref/compiled-fixtures.test.tsx @@ -0,0 +1,328 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createElement } from 'preact'; + +import { + installElementTemplateCommitHook, + resetElementTemplateCommitState, +} from '../../../../../src/element-template/background/commit-hook.js'; +import { + installElementTemplateHydrationListener, + resetElementTemplateHydrationListener, +} from '../../../../../src/element-template/background/hydration-listener.js'; +import { BackgroundElementTemplateInstance } from '../../../../../src/element-template/background/instance.js'; +import { root } from '../../../../../src/element-template/index.js'; +import { clearRefState } from '../../../../../src/element-template/prop-adapters/ref.js'; +import { ElementTemplateLifecycleConstant } from '../../../../../src/element-template/protocol/lifecycle-constant.js'; +import { ElementTemplateUpdateOps } from '../../../../../src/element-template/protocol/opcodes.js'; +import type { ElementTemplateUpdateCommitContext } from '../../../../../src/element-template/protocol/types.js'; +import { clearEtAttrPlanMap } from '../../../../../src/element-template/runtime/template/attr-slot-plan.js'; +import { __root } from '../../../../../src/element-template/runtime/page/root-instance.js'; +import { compileFixtureSource } from '../../../test-utils/debug/compiledFixtureCompiler.js'; +import { + loadCompiledFixtureModule, + type CompiledFixtureModuleExports, +} from '../../../test-utils/debug/compiledFixtureModule.js'; +import { primeCompiledFixtureTemplates } from '../../../test-utils/debug/compiledFixtureRegistry.js'; +import { ElementTemplateEnvManager } from '../../../test-utils/debug/envManager.js'; + +declare const renderPage: () => void; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DIRECT_REF_FIXTURE = path.resolve(__dirname, '../../../fixtures/background/ref/direct-ref/index.tsx'); +const SPREAD_REF_FIXTURE = path.resolve(__dirname, '../../../fixtures/background/ref/spread-ref/index.tsx'); +const MULTI_REF_FIXTURE = path.resolve(__dirname, '../../../fixtures/background/ref/multi-ref/index.tsx'); +const UNSUPPORTED_REF_FIXTURE = path.resolve(__dirname, '../../../fixtures/background/ref/unsupported-ref/index.tsx'); + +interface DirectFixtureProps { + hostRef?: unknown; +} + +interface SpreadFixtureProps { + id?: string; + ref?: unknown; + 'main-thread:ref'?: unknown; + 'worklet:ref'?: unknown; +} + +interface SpreadAppProps { + spread?: SpreadFixtureProps; +} + +interface MultiRefAppProps { + directRef?: unknown; + objectRef?: unknown; + spread?: SpreadFixtureProps; +} + +interface UnsupportedFixtureProps { + mainThreadRef?: unknown; + workletRef?: unknown; +} + +interface CompiledAppModule extends CompiledFixtureModuleExports { + App: (props: TProps) => JSX.Element; +} + +async function loadCompiledFixture( + sourcePath: string, +): Promise<{ + backgroundModule: T; + mainModule: T; +}> { + const mainArtifact = await compileFixtureSource(sourcePath, { target: 'LEPUS' }); + primeCompiledFixtureTemplates(mainArtifact); + const mainModule = await loadCompiledFixtureModule(mainArtifact); + + const backgroundArtifact = await compileFixtureSource(sourcePath, { target: 'JS' }); + const backgroundModule = await loadCompiledFixtureModule(backgroundArtifact); + + return { backgroundModule, mainModule }; +} + +function getRenderedHost(): BackgroundElementTemplateInstance { + const host = (__root as BackgroundElementTemplateInstance).firstChild; + if (!host) { + throw new Error('Missing rendered host.'); + } + return host; +} + +describe('Compiled ordinary ref background updates', () => { + const envManager = new ElementTemplateEnvManager(); + let updateEvents: ElementTemplateUpdateCommitContext[] = []; + const onUpdate = (event: { data: unknown }) => { + updateEvents.push(event.data as ElementTemplateUpdateCommitContext); + }; + + function renderOnBackground( + moduleExports: CompiledAppModule, + props: TProps, + ): BackgroundElementTemplateInstance { + envManager.switchToBackground(); + root.render(createElement(moduleExports.App, props)); + return getRenderedHost(); + } + + function hydrateFromMainThread( + moduleExports: CompiledAppModule, + props: TProps, + ): BackgroundElementTemplateInstance { + const host = getRenderedHost(); + + envManager.switchToMainThread(); + root.render(createElement(moduleExports.App, props)); + renderPage(); + envManager.switchToBackground(); + + return host; + } + + beforeEach(() => { + vi.clearAllMocks(); + resetElementTemplateCommitState(); + resetElementTemplateHydrationListener(); + clearEtAttrPlanMap(); + clearRefState(); + updateEvents = []; + envManager.resetEnv('background'); + envManager.setUseElementTemplate(true); + installElementTemplateCommitHook(); + installElementTemplateHydrationListener(); + + envManager.switchToMainThread(); + lynx.getJSContext().addEventListener(ElementTemplateLifecycleConstant.update, onUpdate); + envManager.switchToBackground(); + }); + + afterEach(() => { + envManager.switchToMainThread(); + lynx.getJSContext().removeEventListener(ElementTemplateLifecycleConstant.update, onUpdate); + envManager.switchToBackground(); + resetElementTemplateHydrationListener(); + clearRefState(); + envManager.setUseElementTemplate(false); + }); + + it('hydrates compiled direct refs and applies later ref-only updates without native patches', async () => { + const { backgroundModule, mainModule } = await loadCompiledFixture>( + DIRECT_REF_FIXTURE, + ); + const oldRef = vi.fn(); + const newRef = vi.fn(); + + const host = renderOnBackground(backgroundModule, { hostRef: oldRef }); + expect(oldRef).toHaveBeenCalledTimes(1); + + hydrateFromMainThread(mainModule, { hostRef: oldRef }); + expect(oldRef).toHaveBeenCalledTimes(1); + expect(host.attributeSlots).toEqual([`${host.instanceId}-0`]); + oldRef.mockClear(); + updateEvents = []; + + renderOnBackground(backgroundModule, { hostRef: newRef }); + + envManager.switchToMainThread(); + expect(updateEvents).toEqual([]); + envManager.switchToBackground(); + expect(oldRef).toHaveBeenCalledWith(null); + expect(newRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: `[ref=${host.instanceId}-0]`, + })); + }); + + it('hydrates compiled spread refs, skips unsupported ref-like keys, and dedupes wrapper churn', async () => { + const { backgroundModule, mainModule } = await loadCompiledFixture>( + SPREAD_REF_FIXTURE, + ); + const stableRef = vi.fn(); + const newRef = vi.fn(); + const unsupportedMainThreadRef = vi.fn(); + const unsupportedWorkletRef = vi.fn(); + + const host = renderOnBackground(backgroundModule, { + spread: { + id: 'cta', + ref: stableRef, + 'main-thread:ref': unsupportedMainThreadRef, + 'worklet:ref': unsupportedWorkletRef, + }, + }); + expect(stableRef).toHaveBeenCalledTimes(1); + + hydrateFromMainThread(mainModule, { + spread: { + id: 'cta', + ref: stableRef, + 'main-thread:ref': unsupportedMainThreadRef, + 'worklet:ref': unsupportedWorkletRef, + }, + }); + + const preparedSpread = { id: 'cta', ref: `${host.instanceId}-0` }; + expect(stableRef).toHaveBeenCalledTimes(1); + expect(host.attributeSlots).toEqual([preparedSpread]); + stableRef.mockClear(); + updateEvents = []; + + renderOnBackground(backgroundModule, { + spread: { id: 'cta-next', ref: stableRef }, + }); + + envManager.switchToMainThread(); + expect(updateEvents.at(-1)?.ops).toEqual([ + ElementTemplateUpdateOps.setAttribute, + host.instanceId, + 0, + { id: 'cta-next', ref: `${host.instanceId}-0` }, + ]); + envManager.switchToBackground(); + expect(stableRef).not.toHaveBeenCalled(); + updateEvents = []; + + renderOnBackground(backgroundModule, { + spread: { id: 'cta-next', ref: newRef }, + }); + + envManager.switchToMainThread(); + expect(updateEvents).toEqual([]); + envManager.switchToBackground(); + expect(stableRef).toHaveBeenCalledWith(null); + expect(newRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: `[ref=${host.instanceId}-0]`, + })); + expect(unsupportedMainThreadRef).not.toHaveBeenCalled(); + expect(unsupportedWorkletRef).not.toHaveBeenCalled(); + }); + + it('hydrates compiled templates with multiple ref slots independently', async () => { + const { backgroundModule, mainModule } = await loadCompiledFixture>( + MULTI_REF_FIXTURE, + ); + const directRef = vi.fn(); + const objectRef: { current: unknown } = { current: null }; + const spreadRef = vi.fn(); + const props = { + directRef, + objectRef, + spread: { + id: 'cta', + ref: spreadRef, + }, + }; + + const host = renderOnBackground(backgroundModule, props); + const initialDirectSelector = `[ref=${host.instanceId}-0]`; + const initialObjectSelector = `[ref=${host.instanceId}-1]`; + const initialSpreadSelector = `[ref=${host.instanceId}-2]`; + expect(host.attributeSlots).toEqual([ + `${host.instanceId}-0`, + `${host.instanceId}-1`, + { id: 'cta', ref: `${host.instanceId}-2` }, + ]); + expect(directRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: initialDirectSelector, + })); + expect(objectRef.current).toMatchObject({ + selector: initialObjectSelector, + }); + expect(spreadRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: initialSpreadSelector, + })); + + hydrateFromMainThread(mainModule, props); + expect(directRef).toHaveBeenCalledTimes(1); + expect(spreadRef).toHaveBeenCalledTimes(1); + const stableDirectSelector = `[ref=${host.instanceId}-0]`; + const stableSpreadSelector = `[ref=${host.instanceId}-2]`; + const stableObjectProxy = objectRef.current; + directRef.mockClear(); + spreadRef.mockClear(); + updateEvents = []; + + const nextDirectRef = vi.fn(); + const nextSpreadRef = vi.fn(); + renderOnBackground(backgroundModule, { + directRef: nextDirectRef, + objectRef, + spread: { + id: 'cta', + ref: nextSpreadRef, + }, + }); + + envManager.switchToMainThread(); + expect(updateEvents).toEqual([]); + envManager.switchToBackground(); + expect(directRef).toHaveBeenCalledWith(null); + expect(nextDirectRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: stableDirectSelector, + })); + expect(objectRef.current).toBe(stableObjectProxy); + expect(spreadRef).toHaveBeenCalledWith(null); + expect(nextSpreadRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: stableSpreadSelector, + })); + }); + + it('drops compiled unsupported namespaced refs before native payloads', async () => { + const { backgroundModule, mainModule } = await loadCompiledFixture>( + UNSUPPORTED_REF_FIXTURE, + ); + const mainThreadRef = vi.fn(); + const workletRef = vi.fn(); + + const props = { mainThreadRef, workletRef }; + const host = renderOnBackground(backgroundModule, props); + expect(host.attributeSlots).toEqual([null, null]); + + hydrateFromMainThread(mainModule, props); + + expect(host.attributeSlots).toEqual([null, null]); + expect(mainThreadRef).not.toHaveBeenCalled(); + expect(workletRef).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/root-render.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/root-render.test.tsx index 8dfce603d7..4c8ea2fc70 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/root-render.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/background/root-render.test.tsx @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetElementTemplateCommitState } from '../../../../src/element-template/background/commit-hook.js'; +import { BackgroundElementTemplateInstance } from '../../../../src/element-template/background/instance.js'; +import { callDestroyLifetimeFun } from '../../../../src/element-template/native/callDestroyLifetimeFun.js'; import { root } from '../../../../src/element-template/index.js'; +import { clearRefState, flushPendingRefs } from '../../../../src/element-template/prop-adapters/ref.js'; import { __root } from '../../../../src/element-template/runtime/page/root-instance.js'; +import { + __etAttrPlanMap, + adaptRefAttrSlot, + clearEtAttrPlanMap, +} from '../../../../src/element-template/runtime/template/attr-slot-plan.js'; import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; describe('ElementTemplate root render timing', () => { @@ -9,6 +18,9 @@ describe('ElementTemplate root render timing', () => { beforeEach(() => { vi.clearAllMocks(); + clearEtAttrPlanMap(); + clearRefState(); + resetElementTemplateCommitState(); envManager.resetEnv('background'); }); @@ -30,4 +42,21 @@ describe('ElementTemplate root render timing', () => { expect(performance.profileStart).toHaveBeenCalledWith('ReactLynx::renderBackground'); expect(performance.profileEnd).toHaveBeenCalled(); }); + + it('cleans direct refs through root unmount on background destroy', () => { + const ref = { current: null }; + + root.render(); + const instance = (__root as BackgroundElementTemplateInstance).firstChild; + expect(instance).toBeInstanceOf(BackgroundElementTemplateInstance); + __etAttrPlanMap[instance!.type] = [0, adaptRefAttrSlot]; + instance?.setAttribute('attributeSlots', [ref]); + expect(instance?.attributeSlots).toEqual([`${instance?.instanceId}-0`]); + flushPendingRefs(); + expect(ref.current).toMatchObject({ selector: expect.stringMatching(/^\[ref=\d+-0\]$/) }); + + callDestroyLifetimeFun(); + + expect(ref.current).toBeNull(); + }); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts b/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts index be94658d2b..ee7876314c 100644 --- a/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts @@ -17,12 +17,19 @@ import { publishEvent, resetEventStateForRuntime, } from '../../../../src/element-template/prop-adapters/event.js'; +import { + clearRefState, + flushDelayedRefUiOps, + flushPendingRefs, +} from '../../../../src/element-template/prop-adapters/ref.js'; import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; import type { SerializedElementTemplate } from '../../../../src/element-template/protocol/types.js'; import { __root } from '../../../../src/element-template/runtime/page/root-instance.js'; import { __etAttrPlanMap, adaptEventAttrSlot, + adaptRefAttrSlot, + adaptSpreadAttrSlot, clearEtAttrPlanMap, } from '../../../../src/element-template/runtime/template/attr-slot-plan.js'; import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; @@ -54,6 +61,7 @@ describe('ElementTemplate hydration listener', () => { vi.clearAllMocks(); clearEtAttrPlanMap(); clearEventState(); + clearRefState(); resetElementTemplateHydrationListener(); envManager.resetEnv('background'); }); @@ -61,6 +69,7 @@ describe('ElementTemplate hydration listener', () => { afterEach(() => { globalThis.__ALOG__ = true; resetElementTemplateHydrationListener(); + clearRefState(); }); it('hydrates instances sent from main thread', () => { @@ -164,7 +173,7 @@ describe('ElementTemplate hydration listener', () => { expect(() => envManager.switchToBackground()).toThrow(dispatchError); expect(globalCommitContext.ops).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtrees).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); vi.advanceTimersByTime(10000); expect(backgroundElementTemplateInstanceManager.get(stale.instanceId)).toBeUndefined(); @@ -174,6 +183,52 @@ describe('ElementTemplate hydration listener', () => { } }); + it('clears pending direct refs when hydrate update dispatch throws', () => { + const dispatchError = new Error('hydrate update dispatch failed'); + const ref = vi.fn(); + let dispatchSpy: ReturnType | undefined; + + try { + __etAttrPlanMap._et_ref_parent = [0, adaptRefAttrSlot]; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + dispatchSpy = vi.spyOn(lynx.getCoreContext(), 'dispatchEvent').mockImplementationOnce(() => { + throw dispatchError; + }); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const parent = new BackgroundElementTemplateInstance('_et_parent'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const inserted = new BackgroundElementTemplateInstance('_et_ref_parent'); + inserted.setAttribute('attributeSlots', [ref]); + slot.appendChild(inserted); + backgroundRoot.appendChild(parent); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_parent', + attributeSlots: [], + elementSlots: [[]], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + expect(() => envManager.switchToBackground()).toThrow(dispatchError); + expect(ref).not.toHaveBeenCalled(); + + envManager.switchToBackground(); + expect(ref).not.toHaveBeenCalled(); + } finally { + dispatchSpy?.mockRestore(); + } + }); + it('does nothing when events are flushed on main thread', () => { envManager.switchToBackground(); installElementTemplateHydrationListener(); @@ -254,6 +309,349 @@ describe('ElementTemplate hydration listener', () => { expect(handler).toHaveBeenCalledWith(eventData); }); + it('drops queued direct events when hydrate matching fails', () => { + __etAttrPlanMap._et_event = [0, adaptEventAttrSlot]; + resetEventStateForRuntime(); + const oldReportError = lynx.reportError; + const reportError = vi.fn(); + lynx.reportError = reportError; + + try { + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const eventData = { type: 'tap' }; + const handler = vi.fn(); + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_event'); + after.setAttribute('attributeSlots', [handler]); + backgroundRoot.appendChild(after); + + publishEvent('-1:0:', eventData); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_mismatch', + attributeSlots: ['-1:0:'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_event', + attributeSlots: ['-1:0:'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + expect(reportError).toHaveBeenCalledTimes(1); + expect(handler).not.toHaveBeenCalled(); + } finally { + lynx.reportError = oldReportError; + } + }); + + it('drops queued direct events when hydrate update dispatch throws', () => { + const dispatchError = new Error('hydrate update dispatch failed'); + const eventData = { type: 'tap' }; + const handler = vi.fn(); + let dispatchSpy: ReturnType | undefined; + + try { + __etAttrPlanMap._et_event_parent = [0, adaptEventAttrSlot]; + resetEventStateForRuntime(); + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + dispatchSpy = vi.spyOn(lynx.getCoreContext(), 'dispatchEvent').mockImplementationOnce(() => { + throw dispatchError; + }); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const parent = new BackgroundElementTemplateInstance('_et_event_parent'); + parent.setAttribute('attributeSlots', [handler]); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + parent.appendChild(slot); + const stale = new BackgroundElementTemplateInstance('_et_stale'); + slot.appendChild(stale); + backgroundRoot.appendChild(parent); + + publishEvent('-1:0:', eventData); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_event_parent', + attributeSlots: ['-1:0:'], + elementSlots: [[]], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + expect(() => envManager.switchToBackground()).toThrow(dispatchError); + expect(handler).not.toHaveBeenCalled(); + + dispatchSpy.mockRestore(); + dispatchSpy = undefined; + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_event_parent', + attributeSlots: ['-1:0:'], + elementSlots: [[]], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + expect(handler).not.toHaveBeenCalled(); + } finally { + dispatchSpy?.mockRestore(); + } + }); + + it('does not attach pending direct refs during hydrate', () => { + const ref = vi.fn(); + __etAttrPlanMap._et_ref = [0, adaptRefAttrSlot]; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_ref'); + after.setAttribute('attributeSlots', [ref]); + backgroundRoot.appendChild(after); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_ref', + attributeSlots: ['-1-0'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + expect(ref).not.toHaveBeenCalled(); + }); + + it('does not re-attach pre-hydration refs and replays delayed ref ops after hydrate', () => { + const exec = vi.fn(); + const setNativeProps = vi.fn(() => ({ exec })); + const select = vi.fn(() => ({ setNativeProps })); + const createSelectorQuery = vi.fn(() => ({ select })); + const oldCreateSelectorQuery = lynx.createSelectorQuery; + lynx.createSelectorQuery = createSelectorQuery as typeof lynx.createSelectorQuery; + + try { + const ref = vi.fn(); + __etAttrPlanMap._et_ref = [0, adaptRefAttrSlot]; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_ref'); + after.setAttribute('attributeSlots', [ref]); + backgroundRoot.appendChild(after); + flushPendingRefs(); + const proxy = ref.mock.calls[0]![0]; + proxy.setNativeProps({ opacity: 1 }).exec(); + expect(select).not.toHaveBeenCalled(); + ref.mockClear(); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_ref', + attributeSlots: ['-1-0'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + expect(ref).not.toHaveBeenCalled(); + expect(select).toHaveBeenCalledWith('[ref=-1-0]'); + expect(setNativeProps).toHaveBeenCalledWith({ opacity: 1 }); + expect(exec).toHaveBeenCalledTimes(1); + } finally { + lynx.createSelectorQuery = oldCreateSelectorQuery; + } + }); + + it('does not re-attach pre-hydration spread refs and replays delayed ref ops after hydrate', () => { + const exec = vi.fn(); + const setNativeProps = vi.fn(() => ({ exec })); + const select = vi.fn(() => ({ setNativeProps })); + const createSelectorQuery = vi.fn(() => ({ select })); + const oldCreateSelectorQuery = lynx.createSelectorQuery; + lynx.createSelectorQuery = createSelectorQuery as typeof lynx.createSelectorQuery; + + try { + const ref = vi.fn(); + __etAttrPlanMap._et_spread = [0, adaptSpreadAttrSlot]; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_spread'); + after.setAttribute('attributeSlots', [{ ref }]); + backgroundRoot.appendChild(after); + flushPendingRefs(); + const proxy = ref.mock.calls[0]![0]; + proxy.setNativeProps({ opacity: 1 }).exec(); + expect(select).not.toHaveBeenCalled(); + ref.mockClear(); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_spread', + attributeSlots: [{ ref: '-1-0' }], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + expect(ref).not.toHaveBeenCalled(); + expect(select).toHaveBeenCalledWith('[ref=-1-0]'); + expect(setNativeProps).toHaveBeenCalledWith({ opacity: 1 }); + expect(exec).toHaveBeenCalledTimes(1); + } finally { + lynx.createSelectorQuery = oldCreateSelectorQuery; + } + }); + + it('detaches and attaches spread refs on real updates after hydrate', () => { + const oldRef = vi.fn(); + const newRef = vi.fn(); + __etAttrPlanMap._et_spread = [0, adaptSpreadAttrSlot]; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_spread'); + after.setAttribute('attributeSlots', [{ ref: oldRef }]); + backgroundRoot.appendChild(after); + flushPendingRefs(); + oldRef.mockClear(); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_spread', + attributeSlots: [{ ref: '-1-0' }], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + expect(oldRef).not.toHaveBeenCalled(); + + after.setAttribute('attributeSlots', [{ ref: newRef }]); + flushPendingRefs(); + + expect(oldRef).toHaveBeenCalledWith(null); + expect(newRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-1-0]', + })); + }); + + it('drops delayed ref ops when hydrate fails before stable handle binding', () => { + const exec = vi.fn(); + const setNativeProps = vi.fn(() => ({ exec })); + const select = vi.fn(() => ({ setNativeProps })); + const createSelectorQuery = vi.fn(() => ({ select })); + const oldCreateSelectorQuery = lynx.createSelectorQuery; + const oldReportError = lynx.reportError; + const reportError = vi.fn(); + lynx.createSelectorQuery = createSelectorQuery as typeof lynx.createSelectorQuery; + lynx.reportError = reportError; + + try { + const ref = vi.fn(); + __etAttrPlanMap._et_ref = [0, adaptRefAttrSlot]; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_ref'); + after.setAttribute('attributeSlots', [ref]); + backgroundRoot.appendChild(after); + flushPendingRefs(); + const proxy = ref.mock.calls[0]![0]; + proxy.setNativeProps({ opacity: 1 }).exec(); + expect(select).not.toHaveBeenCalled(); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_mismatch', + attributeSlots: ['-1-0'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + flushDelayedRefUiOps(); + + expect(reportError).toHaveBeenCalledTimes(1); + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain( + 'ElementTemplate hydrate key mismatch', + ); + expect(select).not.toHaveBeenCalled(); + expect(setNativeProps).not.toHaveBeenCalled(); + expect(exec).not.toHaveBeenCalled(); + } finally { + lynx.createSelectorQuery = oldCreateSelectorQuery; + lynx.reportError = oldReportError; + } + }); + it('marks hydrate performance timings on background thread', () => { envManager.switchToBackground(); installElementTemplateHydrationListener(); diff --git a/packages/react/runtime/__test__/element-template/runtime/prop-adapters/ref.test.ts b/packages/react/runtime/__test__/element-template/runtime/prop-adapters/ref.test.ts new file mode 100644 index 0000000000..b240eb6789 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/prop-adapters/ref.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hydrationMap } from '../../../../src/element-template/hydration-map.js'; +import { + clearRefState, + flushDelayedRefUiOps, + flushPendingRefs, + getRefValue, + prepareRefAttrSlot, + queueRefAttrUpdate, +} from '../../../../src/element-template/prop-adapters/ref.js'; + +describe('ElementTemplate ref prop adapter', () => { + beforeEach(() => { + clearRefState(); + }); + + it('prepares direct ref values as stable native markers', () => { + expect(getRefValue(-2, 0)).toBe('-2-0'); + expect(prepareRefAttrSlot(-2, 0, () => {})).toBe('-2-0'); + expect(prepareRefAttrSlot(7, 3, { current: null })).toBe('7-3'); + expect(prepareRefAttrSlot(-2, 0, 1)).toBe('-2-0'); + expect(prepareRefAttrSlot(7, 3, null)).toBeNull(); + expect(prepareRefAttrSlot(7, 3, undefined)).toBeNull(); + }); + + it('rejects non-marker invalid ref values like the Snapshot runtime', () => { + const error = 'Elements\' "ref" property should be a function, or an object created by createRef()'; + + expect(() => prepareRefAttrSlot(-2, 0, false)).toThrowError(error); + expect(() => prepareRefAttrSlot(-2, 0, 'ref')).toThrowError(error); + expect(() => prepareRefAttrSlot(-2, 0, {})).toThrowError(error); + }); + + it('attaches function refs with an ET selector proxy', () => { + const ref = vi.fn(); + + queueRefAttrUpdate(null, ref, -2, 0); + flushPendingRefs(); + + expect(ref).toHaveBeenCalledTimes(1); + expect(ref.mock.calls[0]![0]).toMatchObject({ + selector: '[ref=-2-0]', + }); + }); + + it('detaches function refs through cleanup when the callback returned one', () => { + const cleanup = vi.fn(); + const ref = vi.fn(() => cleanup); + + queueRefAttrUpdate(null, ref, -2, 0); + flushPendingRefs(); + ref.mockClear(); + + queueRefAttrUpdate(ref, null, -2, 0); + flushPendingRefs(); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(ref).not.toHaveBeenCalled(); + }); + + it('detaches function refs with null when there is no cleanup', () => { + const ref = vi.fn(); + + queueRefAttrUpdate(null, ref, -2, 0); + flushPendingRefs(); + ref.mockClear(); + + queueRefAttrUpdate(ref, null, -2, 0); + flushPendingRefs(); + + expect(ref).toHaveBeenCalledWith(null); + }); + + it('updates object refs and skips unchanged identities', () => { + const ref = { current: null }; + + queueRefAttrUpdate(null, ref, -2, 0); + flushPendingRefs(); + + expect(ref.current).toMatchObject({ + selector: '[ref=-2-0]', + }); + const proxy = ref.current; + + queueRefAttrUpdate(ref, ref, -2, 0); + flushPendingRefs(); + expect(ref.current).toBe(proxy); + + queueRefAttrUpdate(ref, null, -2, 0); + flushPendingRefs(); + expect(ref.current).toBeNull(); + }); + + it('delays NodesRef methods until hydration binds the stable handle', () => { + const exec = vi.fn(); + const setNativeProps = vi.fn(() => ({ exec })); + const select = vi.fn(() => ({ setNativeProps })); + const createSelectorQuery = vi.fn(() => ({ select })); + vi.stubGlobal('lynx', { createSelectorQuery }); + + try { + const ref = vi.fn(); + queueRefAttrUpdate(null, ref, -2, 0); + flushPendingRefs(); + + ref.mock.calls[0]![0].setNativeProps({ opacity: 1 }).exec(); + expect(select).not.toHaveBeenCalled(); + + flushDelayedRefUiOps(); + + expect(select).toHaveBeenCalledWith('[ref=-2-0]'); + expect(setNativeProps).toHaveBeenCalledWith({ opacity: 1 }); + expect(exec).toHaveBeenCalledTimes(1); + + select.mockClear(); + setNativeProps.mockClear(); + exec.mockClear(); + + ref.mock.calls[0]![0].setNativeProps({ opacity: 2 }).exec(); + expect(select).toHaveBeenCalledWith('[ref=-2-0]'); + expect(setNativeProps).toHaveBeenCalledWith({ opacity: 2 }); + expect(exec).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('resolves delayed selectors through hydrated handle ids', () => { + const exec = vi.fn(); + const setNativeProps = vi.fn(() => ({ exec })); + const select = vi.fn(() => ({ setNativeProps })); + const createSelectorQuery = vi.fn(() => ({ select })); + vi.stubGlobal('lynx', { createSelectorQuery }); + + try { + const ref = vi.fn(); + queueRefAttrUpdate(null, ref, 1, 0); + flushPendingRefs(); + + ref.mock.calls[0]![0].setNativeProps({ opacity: 1 }).exec(); + hydrationMap.set(1, -2); + flushDelayedRefUiOps(); + + expect(select).toHaveBeenCalledWith('[ref=-2-0]'); + expect(setNativeProps).toHaveBeenCalledWith({ opacity: 1 }); + expect(exec).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/react/runtime/__test__/element-template/runtime/prop-adapters/spread.test.ts b/packages/react/runtime/__test__/element-template/runtime/prop-adapters/spread.test.ts index c616533ac8..9e849f1ddf 100644 --- a/packages/react/runtime/__test__/element-template/runtime/prop-adapters/spread.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/prop-adapters/spread.test.ts @@ -32,6 +32,7 @@ describe('ElementTemplate spread prop adapter', () => { class: 'final', id: 'cta', name: 'submit', + ref: '-1-0', bindtap: getEventValue(-1, 0, 'bindtap'), }); }); @@ -56,6 +57,38 @@ describe('ElementTemplate spread prop adapter', () => { }); }); + it('emits ordinary ref markers from spread values', () => { + const ref = vi.fn(); + const prepared = prepareSpreadAttrSlot(-4, 1, { + id: 'cta', + ref, + }); + + expect(prepared).toEqual({ + id: 'cta', + ref: '-4-1', + }); + }); + + it('emits null for explicit nullish spread refs', () => { + expect(prepareSpreadAttrSlot(-4, 1, { ref: null })).toEqual({ ref: null }); + expect(prepareSpreadAttrSlot(-4, 1, { ref: undefined })).toEqual({ ref: null }); + }); + + it('uses ordinary ref validation for spread refs', () => { + const error = 'Elements\' "ref" property should be a function, or an object created by createRef()'; + + expect(() => prepareSpreadAttrSlot(-4, 1, { ref: false })).toThrowError(error); + expect(() => prepareSpreadAttrSlot(-4, 1, { ref: {} })).toThrowError(error); + }); + + it('ignores inherited spread refs', () => { + const spread = Object.create({ ref: vi.fn() }) as Record; + spread.id = 'cta'; + + expect(prepareSpreadAttrSlot(-4, 1, spread)).toEqual({ id: 'cta' }); + }); + it('returns null for removed spread values', () => { expect(prepareSpreadAttrSlot(-4, 0, null)).toBeNull(); expect(prepareSpreadAttrSlot(-4, 0, false)).toBeNull(); @@ -63,7 +96,7 @@ describe('ElementTemplate spread prop adapter', () => { it('ignores non-host spread props', () => { const prepared = prepareSpreadAttrSlot(-5, 0, { - ref: vi.fn(), + 'worklet:ref': vi.fn(), 'main-thread:ref': vi.fn(), 'main-thread:bindtap': vi.fn(), 'main-thread:gesture': {}, diff --git a/packages/react/runtime/__test__/element-template/runtime/render/render-opcodes-into-element-template.et.test.tsx b/packages/react/runtime/__test__/element-template/runtime/render/render-opcodes-into-element-template.et.test.tsx index dae84c88c1..fb2a7db2df 100644 --- a/packages/react/runtime/__test__/element-template/runtime/render/render-opcodes-into-element-template.et.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/render/render-opcodes-into-element-template.et.test.tsx @@ -10,6 +10,7 @@ import { elementTemplateRegistry } from '../../../../src/element-template/runtim import { __etAttrPlanMap, adaptEventAttrSlot, + adaptRefAttrSlot, adaptSpreadAttrSlot, clearEtAttrPlanMap, } from '../../../../src/element-template/runtime/template/attr-slot-plan.js'; @@ -127,6 +128,31 @@ describe('renderOpcodesIntoElementTemplate', () => { expect(addEvent).not.toHaveBeenCalled(); }); + it('prepares direct ref values before native create', () => { + const rootRef = { kind: 'root-ref' }; + const ref = vi.fn(); + createElementTemplate.mockReturnValue(rootRef); + __etAttrPlanMap._et_ref = [0, adaptRefAttrSlot]; + + renderOpcodesIntoElementTemplate([ + __OpBegin, + { type: '_et_ref' }, + __OpAttr, + 'attributeSlots', + [ref], + __OpEnd, + ]); + + expect(createElementTemplate).toHaveBeenCalledWith( + '_et_ref', + null, + ['-1-0'], + null, + -1, + ); + expect(ref).not.toHaveBeenCalled(); + }); + it('prepares spread event values before native create', () => { const rootRef = { kind: 'root-ref' }; const handleTap = vi.fn(); @@ -159,6 +185,36 @@ describe('renderOpcodesIntoElementTemplate', () => { expect(addEvent).not.toHaveBeenCalled(); }); + it('prepares spread ref values before native create without leaking unsupported ref-like props', () => { + const rootRef = { kind: 'root-ref' }; + const ref = vi.fn(); + createElementTemplate.mockReturnValue(rootRef); + __etAttrPlanMap._et_spread = [0, adaptSpreadAttrSlot]; + + renderOpcodesIntoElementTemplate([ + __OpBegin, + { type: '_et_spread' }, + __OpAttr, + 'attributeSlots', + [{ + id: 'cta', + ref, + 'main-thread:ref': vi.fn(), + 'worklet:ref': vi.fn(), + }], + __OpEnd, + ]); + + expect(createElementTemplate).toHaveBeenCalledWith( + '_et_spread', + null, + [{ id: 'cta', ref: '-1-0' }], + null, + -1, + ); + expect(ref).not.toHaveBeenCalled(); + }); + it('throws when text is emitted outside of an element slot', () => { expect(() => renderOpcodesIntoElementTemplate([ diff --git a/packages/react/runtime/__test__/element-template/runtime/template/element-template-attr-slot-plan.test.ts b/packages/react/runtime/__test__/element-template/runtime/template/element-template-attr-slot-plan.test.ts index 2503941e12..1fbeeb2467 100644 --- a/packages/react/runtime/__test__/element-template/runtime/template/element-template-attr-slot-plan.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/template/element-template-attr-slot-plan.test.ts @@ -1,14 +1,22 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + prepareAttributeSlots, + queueRefAttributeSlotUpdates, +} from '../../../../src/element-template/background/attr-slots.js'; import { __etAttrPlanMap, + adaptRefAttrSlot, + adaptSpreadAttrSlot, clearEtAttrPlanMap, type EtAttrAdapter, } from '../../../../src/element-template/runtime/template/attr-slot-plan.js'; +import { clearRefState, flushPendingRefs } from '../../../../src/element-template/prop-adapters/ref.js'; describe('ElementTemplate attr slot plan registry', () => { afterEach(() => { clearEtAttrPlanMap(); + clearRefState(); }); it('uses undefined as the unregistered template fast path', () => { @@ -25,4 +33,106 @@ describe('ElementTemplate attr slot plan registry', () => { expect(__etAttrPlanMap).toBe(map); expect(__etAttrPlanMap._et_card).toBeUndefined(); }); + + it('prepares direct ref slots as native ref markers', () => { + expect(adaptRefAttrSlot(-2, 0, () => {})).toBe('-2-0'); + expect(adaptRefAttrSlot(17, 3, { current: null })).toBe('17-3'); + expect(adaptRefAttrSlot(-2, 0, 1)).toBe('-2-0'); + }); + + it('normalizes empty direct ref slots to null', () => { + expect(adaptRefAttrSlot(-2, 0, null)).toBeNull(); + expect(adaptRefAttrSlot(-2, 0, undefined)).toBeNull(); + }); + + it('rejects non-marker invalid direct ref values like the Snapshot runtime', () => { + const error = 'Elements\' "ref" property should be a function, or an object created by createRef()'; + + expect(() => adaptRefAttrSlot(-2, 0, false)).toThrowError(error); + expect(() => adaptRefAttrSlot(-2, 0, 'ref')).toThrowError(error); + expect(() => adaptRefAttrSlot(-2, 0, {})).toThrowError(error); + }); + + it('prepares registered ref attr slots through the attr plan consumer', () => { + __etAttrPlanMap._et_ref = [0, adaptRefAttrSlot]; + + expect(prepareAttributeSlots('_et_ref', -2, [() => {}])).toEqual(['-2-0']); + expect(prepareAttributeSlots('_et_ref', -2, [1])).toEqual(['-2-0']); + }); + + it('skips queued ref effects for templates without attr plans', () => { + expect(() => { + queueRefAttributeSlotUpdates('_et_without_backend_attrs', -2, [() => {}]); + }).not.toThrow(); + }); + + it('queues registered ref slot updates from previous and next raw slots', () => { + const oldRef = vi.fn(); + const newRef = vi.fn(); + __etAttrPlanMap._et_ref = [0, adaptRefAttrSlot]; + + queueRefAttributeSlotUpdates('_et_ref', -2, [oldRef], [newRef]); + flushPendingRefs(); + + expect(oldRef).toHaveBeenCalledWith(null); + expect(newRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-2-0]', + })); + }); + + it('queues every ref-bearing attr slot independently', () => { + const directRef = vi.fn(); + const objectRef = { current: null }; + const spreadRef = vi.fn(); + __etAttrPlanMap._et_multi_ref = [ + 0, + adaptRefAttrSlot, + 1, + adaptRefAttrSlot, + 2, + adaptSpreadAttrSlot, + ]; + + expect( + prepareAttributeSlots( + '_et_multi_ref', + -7, + [directRef, objectRef, { ref: spreadRef }], + { queueRefEffects: true }, + ), + ).toEqual(['-7-0', '-7-1', { ref: '-7-2' }]); + flushPendingRefs(); + + expect(directRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-7-0]', + })); + expect(objectRef.current).toMatchObject({ + selector: '[ref=-7-1]', + }); + expect(spreadRef).toHaveBeenCalledWith(expect.objectContaining({ + selector: '[ref=-7-2]', + })); + }); + + it('queues spread ref cleanup without detaching sibling direct refs', () => { + const directRef = vi.fn(); + const spreadRef = vi.fn(); + __etAttrPlanMap._et_multi_ref = [ + 0, + adaptRefAttrSlot, + 1, + adaptSpreadAttrSlot, + ]; + + queueRefAttributeSlotUpdates( + '_et_multi_ref', + -7, + [directRef, { ref: spreadRef }], + [directRef, { ref: undefined }], + ); + flushPendingRefs(); + + expect(directRef).not.toHaveBeenCalled(); + expect(spreadRef).toHaveBeenCalledWith(null); + }); }); diff --git a/packages/react/runtime/__test__/snapshot/ref.test.jsx b/packages/react/runtime/__test__/snapshot/ref.test.jsx index e1730fc4a9..a1afb1f8fb 100644 --- a/packages/react/runtime/__test__/snapshot/ref.test.jsx +++ b/packages/react/runtime/__test__/snapshot/ref.test.jsx @@ -2125,6 +2125,24 @@ describe('applyRef before hydration', () => { expect(cb.mock.calls[0][0]).toBeInstanceOf(RefProxy); }); + it('reports ref callback errors without breaking render', async function() { + const error = new Error('ref failed'); + const cb = vi.fn(() => { + throw error; + }); + const reportError = vi.spyOn(lynx, 'reportError'); + + function App() { + return ; + } + + globalEnvManager.switchToBackground(); + expect(() => render(, __root)).not.toThrow(); + + expect(reportError).toHaveBeenCalledTimes(1); + expect(reportError).toHaveBeenCalledWith(error); + }); + it('three consecutive rerenders before hydration clean up intermediate refs', async function() { const cb1 = vi.fn(); const cb2 = vi.fn(); diff --git a/packages/react/runtime/src/core/ref.ts b/packages/react/runtime/src/core/ref.ts new file mode 100644 index 0000000000..a316e6fb82 --- /dev/null +++ b/packages/react/runtime/src/core/ref.ts @@ -0,0 +1,166 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { NodesRef, SelectorQuery } from '@lynx-js/types'; + +export type RefCleanup = (() => void) | void; +export type RefCallback = ((ref: T | null) => RefCleanup) & { + _unmount?: RefCleanup; +}; +export interface RefObject { + current: T | null; +} +export type OrdinaryRef = RefCallback | RefObject; + +type FunctionPropertyNames = { + [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never; +}[keyof T]; + +export type ForwardableNodesRefMethod = Exclude, 'exec'>; +export type RefProxyForwardedMethods = { + [K in ForwardableNodesRefMethod]: (...args: Parameters) => TProxy; +}; + +type RefTask = (nodesRef: NodesRef) => SelectorQuery; + +export function assertValidRef(value: unknown): OrdinaryRef { + if ( + typeof value === 'function' + || (typeof value === 'object' && value !== null && 'current' in value) + ) { + return value as OrdinaryRef; + } + throw new Error( + `Elements' "ref" property should be a function, or an object created ` + + `by createRef(), but got [${typeof value}] instead`, + ); +} + +export function normalizeRefValue(value: unknown): OrdinaryRef | null | undefined { + if (value === null || value === undefined) { + return value; + } + return assertValidRef(value); +} + +export function applyOrdinaryRef( + ref: OrdinaryRef, + value: T | null, +): void { + try { + if (typeof ref === 'function') { + const cleanup = ref._unmount; + const hasCleanup = typeof cleanup === 'function'; + if (hasCleanup) { + cleanup(); + } + ref._unmount = undefined; + + if (!hasCleanup || value !== null) { + const nextCleanup = ref(value); + if (typeof nextCleanup === 'function') { + ref._unmount = nextCleanup; + } + } + } else { + ref.current = value; + } + } catch (error) { + lynx.reportError(error as Error); + } +} + +// Keeps the Snapshot/ET ordinary ref ordering shared without owning backend +// timing: each backend decides when to queue/flush and how to build the proxy. +export class OrdinaryRefEffectQueue { + private readonly refsToClear: OrdinaryRef[] = []; + private readonly refsToApply: Array<[ref: OrdinaryRef, token: TToken]> = []; + + queue( + oldRef: OrdinaryRef | null | undefined, + newRef: OrdinaryRef | null | undefined, + token: TToken, + ): void { + if (oldRef === newRef) { + return; + } + if (oldRef) { + this.refsToClear.push(oldRef); + } + if (newRef) { + this.refsToApply.push([newRef, token]); + } + } + + flush( + createValue: (token: TToken) => TProxy, + ): void { + // Ref callbacks can synchronously trigger more work; detach this batch from + // the queue before invoking user code so later effects stay in the next batch. + const refsToClearNow = this.refsToClear.splice(0); + const refsToApplyNow = this.refsToApply.splice(0); + + for (const ref of refsToClearNow) { + applyOrdinaryRef(ref, null); + } + for (const [ref, token] of refsToApplyNow) { + applyOrdinaryRef(ref, createValue(token)); + } + } + + clear(): void { + this.refsToClear.length = 0; + this.refsToApply.length = 0; + } + + hasPending(): boolean { + return this.refsToClear.length > 0 || this.refsToApply.length > 0; + } +} + +export abstract class SelectorRefProxy> { + private task: RefTask | undefined; + + protected createProxy(): TProxy { + return new Proxy(this, { + get: (target, prop, receiver) => { + if ( + typeof prop === 'symbol' + || prop === 'then' + || prop in target + || typeof prop !== 'string' + ) { + return Reflect.get(target, prop, receiver); + } + + return (...args: Parameters) => { + return target.createProxyTarget().setTask(prop as ForwardableNodesRefMethod, args); + }; + }, + }) as unknown as TProxy; + } + + protected abstract createProxyTarget(): TProxy; + + protected abstract runOrDelay(task: () => void): void; + + abstract get selector(): string; + + private setTask( + method: K, + args: Parameters, + ): TProxy { + this.task = (nodesRef) => { + const nodesRefMethod = nodesRef[method] as (...params: Parameters) => SelectorQuery; + return nodesRefMethod.apply(nodesRef, args); + }; + return this as unknown as TProxy; + } + + exec(): void { + this.runOrDelay(() => { + this.task!(lynx.createSelectorQuery().select(this.selector)).exec(); + }); + } +} diff --git a/packages/react/runtime/src/element-template/background/attr-slots.ts b/packages/react/runtime/src/element-template/background/attr-slots.ts index 3c5b6df843..aef0c33a29 100644 --- a/packages/react/runtime/src/element-template/background/attr-slots.ts +++ b/packages/react/runtime/src/element-template/background/attr-slots.ts @@ -2,10 +2,16 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { getSpreadRefFromValue, queueRefAttrUpdate } from '../prop-adapters/ref.js'; import type { SerializableValue } from '../protocol/types.js'; -import { __etAttrPlanMap } from '../runtime/template/attr-slot-plan.js'; +import { __etAttrPlanMap, adaptRefAttrSlot, adaptSpreadAttrSlot } from '../runtime/template/attr-slot-plan.js'; import type { EtAttrAdapter } from '../runtime/template/attr-slot-plan.js'; +export interface PrepareAttributeSlotsOptions { + previousRawSlots?: readonly unknown[]; + queueRefEffects?: boolean; +} + function normalizeAttributeSlots(rawSlots: readonly unknown[]): SerializableValue[] { let normalizedSlots: SerializableValue[] | undefined; for (let slotIndex = 0; slotIndex < rawSlots.length; slotIndex += 1) { @@ -19,10 +25,47 @@ function normalizeAttributeSlots(rawSlots: readonly unknown[]): SerializableValu return normalizedSlots ?? rawSlots as SerializableValue[]; } +function queuePlannedRefAttributeSlotUpdates( + handleId: number, + attrPlan: readonly (number | EtAttrAdapter)[], + previousRawSlots?: readonly unknown[], + nextRawSlots?: readonly unknown[], +): void { + for (let planIndex = 0; planIndex < attrPlan.length; planIndex += 2) { + const attrSlotIndex = attrPlan[planIndex] as number; + const adapter = attrPlan[planIndex + 1] as EtAttrAdapter; + + if (adapter === adaptRefAttrSlot) { + queueRefAttrUpdate( + previousRawSlots?.[attrSlotIndex], + nextRawSlots?.[attrSlotIndex], + handleId, + attrSlotIndex, + ); + continue; + } + + if (adapter === adaptSpreadAttrSlot) { + const previousSpreadRef = getSpreadRefFromValue(previousRawSlots?.[attrSlotIndex]); + const nextSpreadRef = getSpreadRefFromValue(nextRawSlots?.[attrSlotIndex]); + if (previousSpreadRef === undefined && nextSpreadRef === undefined) { + continue; + } + queueRefAttrUpdate( + previousSpreadRef, + nextSpreadRef ?? null, + handleId, + attrSlotIndex, + ); + } + } +} + export function prepareAttributeSlots( templateKey: string, handleId: number, rawSlots: readonly unknown[], + options?: PrepareAttributeSlotsOptions, ): SerializableValue[] { const attrPlan = __etAttrPlanMap[templateKey]; if (!attrPlan || attrPlan.length === 0) { @@ -33,12 +76,33 @@ export function prepareAttributeSlots( const preparedSlots = normalizedSlots === rawSlots ? rawSlots.slice() as SerializableValue[] : normalizedSlots; + const shouldQueueRefEffects = options?.queueRefEffects === true; + const previousRawSlots = options?.previousRawSlots; for (let planIndex = 0; planIndex < attrPlan.length; planIndex += 2) { const attrSlotIndex = attrPlan[planIndex] as number; const adapter = attrPlan[planIndex + 1] as EtAttrAdapter; const rawValue = rawSlots[attrSlotIndex]; preparedSlots[attrSlotIndex] = adapter(handleId, attrSlotIndex, rawValue); } + if (shouldQueueRefEffects) { + // Ref effects compare raw user refs, not prepared marker strings or the + // spread wrapper object. + queuePlannedRefAttributeSlotUpdates(handleId, attrPlan, previousRawSlots, rawSlots); + } return preparedSlots; } + +export function queueRefAttributeSlotUpdates( + templateKey: string, + handleId: number, + previousRawSlots?: readonly unknown[], + nextRawSlots?: readonly unknown[], +): void { + const attrPlan = __etAttrPlanMap[templateKey]; + if (!attrPlan || attrPlan.length === 0) { + return; + } + + queuePlannedRefAttributeSlotUpdates(handleId, attrPlan, previousRawSlots, nextRawSlots); +} diff --git a/packages/react/runtime/src/element-template/background/commit-context.ts b/packages/react/runtime/src/element-template/background/commit-context.ts index 58bf157460..99735081c1 100644 --- a/packages/react/runtime/src/element-template/background/commit-context.ts +++ b/packages/react/runtime/src/element-template/background/commit-context.ts @@ -6,8 +6,9 @@ import type { ElementTemplateUpdateCommitContext } from '../protocol/types.js'; interface ElementTemplateCommitNonPayloadState { // Background-only JS objects must not be included in the cross-thread update - // payload. They ride alongside the payload until dispatch schedules cleanup. - removedSubtrees: BackgroundElementTemplateInstance[]; + // payload. They ride alongside the payload until the dispatch boundary + // schedules delayed teardown. + removedSubtreesAwaitingTeardown: BackgroundElementTemplateInstance[]; } type ElementTemplateGlobalCommitContext = ElementTemplateUpdateCommitContext & { @@ -18,7 +19,7 @@ export const globalCommitContext: ElementTemplateGlobalCommitContext = { ops: [], flushOptions: {}, nonPayload: { - removedSubtrees: [], + removedSubtreesAwaitingTeardown: [], }, }; @@ -26,20 +27,20 @@ export function resetGlobalCommitContext(): void { globalCommitContext.ops = []; globalCommitContext.flushOptions = {}; delete globalCommitContext.flowIds; - globalCommitContext.nonPayload.removedSubtrees = []; + globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown = []; } -export function markRemovedSubtreeForCurrentCommit( +export function markRemovedSubtreeForPostDispatchTeardown( root: BackgroundElementTemplateInstance, ): void { - const { removedSubtrees } = globalCommitContext.nonPayload; - if (!removedSubtrees.includes(root)) { - removedSubtrees.push(root); + const { removedSubtreesAwaitingTeardown } = globalCommitContext.nonPayload; + if (!removedSubtreesAwaitingTeardown.includes(root)) { + removedSubtreesAwaitingTeardown.push(root); } } -export function takeRemovedSubtreesForCurrentCommit(): BackgroundElementTemplateInstance[] { - const removedSubtrees = globalCommitContext.nonPayload.removedSubtrees; - globalCommitContext.nonPayload.removedSubtrees = []; - return removedSubtrees; +export function takeRemovedSubtreesForPostDispatchTeardown(): BackgroundElementTemplateInstance[] { + const removedSubtreesAwaitingTeardown = globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown; + globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown = []; + return removedSubtreesAwaitingTeardown; } diff --git a/packages/react/runtime/src/element-template/background/commit-hook.ts b/packages/react/runtime/src/element-template/background/commit-hook.ts index ca1b1963c7..f102506c73 100644 --- a/packages/react/runtime/src/element-template/background/commit-hook.ts +++ b/packages/react/runtime/src/element-template/background/commit-hook.ts @@ -7,7 +7,7 @@ import { options } from 'preact'; import { globalCommitContext, resetGlobalCommitContext, - takeRemovedSubtreesForCurrentCommit, + takeRemovedSubtreesForPostDispatchTeardown, } from './commit-context.js'; import type { BackgroundElementTemplateInstance } from './instance.js'; import { COMMIT } from '../../shared/render-constants.js'; @@ -15,11 +15,12 @@ import { hook } from '../../utils.js'; import { formatElementTemplateUpdateCommands } from '../debug/alog.js'; import { profileEnd, profileStart } from '../debug/profile.js'; import { globalPipelineOptions, markTiming, markTimingLegacy, setPipeline } from '../lynx/performance.js'; +import { clearPendingRefs, flushPendingRefs, hasPendingRefs } from '../prop-adapters/ref.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; let installed = false; let hasHydrated = false; -const scheduledRemovedSubtreeCleanupTimers = new Set>(); +const scheduledRemovedSubtreeCleanupTimers = /*#__PURE__*/ new Set>(); export function markElementTemplateHydrated(): void { hasHydrated = true; @@ -35,14 +36,14 @@ export function resetElementTemplateCommitState(): void { } export function scheduleElementTemplateRemovedSubtreeCleanup( - removedSubtrees: BackgroundElementTemplateInstance[], + removedSubtreesAwaitingTeardown: BackgroundElementTemplateInstance[], ): void { - if (removedSubtrees.length === 0) { + if (removedSubtreesAwaitingTeardown.length === 0) { return; } const timer = setTimeout(() => { scheduledRemovedSubtreeCleanupTimers.delete(timer); - for (const root of removedSubtrees) { + for (const root of removedSubtreesAwaitingTeardown) { root.tearDown(); } }, 10000); @@ -63,56 +64,71 @@ export function installElementTemplateCommitHook(): void { installed = true; hook(options, COMMIT, (originalCommit, vnode, commitQueue) => { - if (__BACKGROUND__ && hasHydrated && globalCommitContext.ops.length > 0) { - markTimingLegacy('updateDiffVdomEnd'); - markTiming('diffVdomEnd'); + if (__BACKGROUND__ && !hasHydrated && hasPendingRefs()) { + // User effects can run before ET hydrate arrives, so ordinary refs must be + // attached on the background commit even though native UI ops are delayed. + flushPendingRefs(); + } else if (__BACKGROUND__ && hasHydrated && (globalCommitContext.ops.length > 0 || hasPendingRefs())) { + const hasNativeOps = globalCommitContext.ops.length > 0; + const removedSubtreesAwaitingTeardown = hasNativeOps ? takeRemovedSubtreesForPostDispatchTeardown() : []; + let didFlushRefs = false; + try { + if (hasNativeOps) { + markTimingLegacy('updateDiffVdomEnd'); + markTiming('diffVdomEnd'); - if (__PROFILE__) { - profileStart('ReactLynx::commitChanges'); - } - markTiming('packChangesStart'); - if (globalPipelineOptions) { - globalCommitContext.flushOptions.pipelineOptions = globalPipelineOptions; - } - markTiming('packChangesEnd'); - if (globalPipelineOptions) { - setPipeline(undefined); - } - if (__PROFILE__) { - profileEnd(); - } + if (__PROFILE__) { + profileStart('ReactLynx::commitChanges'); + } + markTiming('packChangesStart'); + if (globalPipelineOptions) { + globalCommitContext.flushOptions.pipelineOptions = globalPipelineOptions; + } + markTiming('packChangesEnd'); + if (globalPipelineOptions) { + setPipeline(undefined); + } + if (__PROFILE__) { + profileEnd(); + } - if (typeof __ALOG__ !== 'undefined' && __ALOG__) { - console.alog?.( - '[ReactLynxDebug] ElementTemplate BTS -> MTS update:\n' - + JSON.stringify( - { - ops: formatElementTemplateUpdateCommands(globalCommitContext.ops), - flushOptions: globalCommitContext.flushOptions, - flowIds: globalCommitContext.flowIds, - }, - null, - 2, - ), - ); - } + if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + console.alog?.( + '[ReactLynxDebug] ElementTemplate BTS -> MTS update:\n' + + JSON.stringify( + { + ops: formatElementTemplateUpdateCommands(globalCommitContext.ops), + flushOptions: globalCommitContext.flushOptions, + flowIds: globalCommitContext.flowIds, + }, + null, + 2, + ), + ); + } - const removedSubtrees = takeRemovedSubtreesForCurrentCommit(); - try { - lynx.getCoreContext().dispatchEvent({ - type: ElementTemplateLifecycleConstant.update, - data: { - ops: globalCommitContext.ops, - flushOptions: globalCommitContext.flushOptions, - flowIds: globalCommitContext.flowIds, - }, - }); + lynx.getCoreContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.update, + data: { + ops: globalCommitContext.ops, + flushOptions: globalCommitContext.flushOptions, + flowIds: globalCommitContext.flowIds, + }, + }); + } + // When native ops exist, patch first so a newly attached ref observes the + // committed native state. Ref-only commits still flush through this path. + flushPendingRefs(); + didFlushRefs = true; } finally { + if (!didFlushRefs) { + clearPendingRefs(); + } resetGlobalCommitContext(); // Match Snapshot's cleanup boundary: start the delayed teardown only // after the bridge dispatch attempt, so background JS objects are not // torn down before main-thread detach observes the same commit. - scheduleElementTemplateRemovedSubtreeCleanup(removedSubtrees); + scheduleElementTemplateRemovedSubtreeCleanup(removedSubtreesAwaitingTeardown); } } diff --git a/packages/react/runtime/src/element-template/background/destroy.ts b/packages/react/runtime/src/element-template/background/destroy.ts index 1a153fb532..89c6843e3c 100644 --- a/packages/react/runtime/src/element-template/background/destroy.ts +++ b/packages/react/runtime/src/element-template/background/destroy.ts @@ -1,18 +1,27 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import type { ContainerNode } from 'preact'; +import { render } from 'preact'; import { cancelElementTemplateRemovedSubtreeCleanup, resetElementTemplateCommitState } from './commit-hook.js'; import { resetElementTemplateHydrationListener } from './hydration-listener.js'; import { backgroundElementTemplateInstanceManager } from './manager.js'; import { clearEventState } from '../prop-adapters/event.js'; +import { clearRefState, flushPendingRefs } from '../prop-adapters/ref.js'; +import { __root } from '../runtime/page/root-instance.js'; export function destroyElementTemplateBackgroundRuntime(): void { resetElementTemplateHydrationListener(); - resetElementTemplateCommitState(); - // Destroy is the only place that may discard delayed removed subtrees instead - // of letting the Snapshot-aligned timer tear them down later. cancelElementTemplateRemovedSubtreeCleanup(); + + render(null, __root as unknown as ContainerNode); + // Run user cleanup before dropping the backend side tables; after clearRefState + // the raw ref ownership needed to detach callbacks is gone. + flushPendingRefs(); + + resetElementTemplateCommitState(); clearEventState(); + clearRefState(); backgroundElementTemplateInstanceManager.clear(); } diff --git a/packages/react/runtime/src/element-template/background/hydrate.ts b/packages/react/runtime/src/element-template/background/hydrate.ts index d683b13cbe..dab902912d 100644 --- a/packages/react/runtime/src/element-template/background/hydrate.ts +++ b/packages/react/runtime/src/element-template/background/hydrate.ts @@ -2,11 +2,16 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { globalCommitContext, markRemovedSubtreeForCurrentCommit, resetGlobalCommitContext } from './commit-context.js'; +import { + globalCommitContext, + markRemovedSubtreeForPostDispatchTeardown, + resetGlobalCommitContext, +} from './commit-context.js'; import { BUILTIN_RAW_TEXT_TEMPLATE_KEY } from './instance.js'; import type { BackgroundElementTemplateInstance } from './instance.js'; import { backgroundElementTemplateInstanceManager } from './manager.js'; import { isDirectOrDeepEqual } from '../../utils.js'; +import { hydrationMap } from '../hydration-map.js'; import { ElementTemplateUpdateOps } from '../protocol/opcodes.js'; import type { ElementTemplateUpdateCommandStream, @@ -19,15 +24,20 @@ export function hydrate( instance: BackgroundElementTemplateInstance, ): ElementTemplateUpdateCommandStream { resetGlobalCommitContext(); - hydrateIntoContext(serialized, instance); + if (!hydrateIntoContext(serialized, instance)) { + // Hydration protocol errors are not transactional: earlier matched nodes + // may already be rebound while discovering a later mismatch. Discard the + // native output for this failed pass and let the listener keep refs/events gated. + resetGlobalCommitContext(); + } return globalCommitContext.ops; } export function hydrateIntoContext( serialized: SerializedElementTemplate, instance: BackgroundElementTemplateInstance, -): void { - hydrateInstance(serialized, instance); +): boolean { + return hydrateInstance(serialized, instance); } interface HydrateChildListDiff { @@ -44,7 +54,7 @@ interface HydrateChildListDiff { function hydrateMatchingChildrenAndDiffSlot( serializedChildren: SerializedElementTemplate[], backgroundChildren: BackgroundElementTemplateInstance[], -): HydrateChildListDiff { +): HydrateChildListDiff | null { let lastPlacedIndex = 0; const result: HydrateChildListDiff = { hasChanges: false, @@ -70,7 +80,9 @@ function hydrateMatchingChildrenAndDiffSlot( if (matchedSerialized) { serializedCursorByTemplateKey[backgroundChild.type] = candidateCursor + 1; const oldIndex = matchedSerialized[1]; - hydrateInstance(matchedSerialized[0], backgroundChild); + if (!hydrateInstance(matchedSerialized[0], backgroundChild)) { + return null; + } if (oldIndex < lastPlacedIndex) { result.moves[oldIndex] = { toIndex: i, instance: backgroundChild }; result.hasChanges = true; @@ -99,7 +111,7 @@ function hydrateMatchingChildrenAndDiffSlot( function hydrateInstance( serialized: SerializedElementTemplate, instance: BackgroundElementTemplateInstance, -): void { +): boolean { if (serialized.templateKey !== instance.type) { if (__DEV__) { lynx.reportError( @@ -108,18 +120,18 @@ function hydrateInstance( ), ); } - return; + return false; } const handleId = serialized.uid as number; if (!bindHydrationHandleId(instance, handleId, serialized.templateKey)) { - return; + return false; } - instance.prepareAttributeSlotsForNative(); + instance.prepareAttributeSlotsForHydration(); hydrateAttributeSlots(handleId, serialized.attributeSlots ?? [], instance.attributeSlots); if (serialized.templateKey === BUILTIN_RAW_TEXT_TEMPLATE_KEY) { - return; + return true; } const serializedElementSlots = serialized.elementSlots ?? []; @@ -133,34 +145,40 @@ function hydrateInstance( if (!serializedSlot && !backgroundSlot) { continue; } - hydrateElementSlot(instance, slotId, serializedSlot ?? []); + if (!hydrateElementSlot(instance, slotId, serializedSlot ?? [])) { + return false; + } } + return true; } function hydrateElementSlot( parent: BackgroundElementTemplateInstance, slotId: number, serializedChildren: SerializedElementTemplate[], -): void { +): boolean { const backgroundChildren = parent.elementSlots[slotId] ?? []; if (backgroundChildren.length === 0) { for (const serialized of serializedChildren) { emitSerializedSubtreeRemove(parent, slotId, serialized); } - return; + return true; } const listDiff = hydrateMatchingChildrenAndDiffSlot(serializedChildren, backgroundChildren); + if (listDiff === null) { + return false; + } if (!listDiff.hasChanges) { - return; + return true; } // Hydrate emits patches directly here. Replaying against serialized order // keeps insert targets in the main-thread slot without reshaping background. const removalIndexes = new Set(listDiff.removals); const { insertions, moves } = listDiff; - const pendingMoves = new Map(); + const movesWaitingForInsertionPoint = new Map(); let serializedCursor = 0; let currentSerializedChild = serializedChildren[serializedCursor]; let newIndex = 0; @@ -168,18 +186,18 @@ function hydrateElementSlot( // Insertions are known before replay starts. Moves are counted only when their // old serialized position is reached, so the cursor can keep emitting patches // even after all serialized children have been consumed. - let pendingInsertOrMovePatchCount = listDiff.insertionCount; - while (currentSerializedChild || pendingInsertOrMovePatchCount > 0) { + let insertOrMovePatchesWaitingForInsertionPoint = listDiff.insertionCount; + while (currentSerializedChild || insertOrMovePatchesWaitingForInsertionPoint > 0) { let keepCurrentSerializedChild = false; if (currentSerializedChild && removalIndexes.has(oldIndex)) { emitSerializedSubtreeRemove(parent, slotId, currentSerializedChild); } else if (currentSerializedChild && moves[oldIndex] !== undefined) { const move = moves[oldIndex]!; - pendingMoves.set(move.toIndex, move.instance); - pendingInsertOrMovePatchCount += 1; + movesWaitingForInsertionPoint.set(move.toIndex, move.instance); + insertOrMovePatchesWaitingForInsertionPoint += 1; } else { const beforeChildId = currentSerializedChild ? currentSerializedChild.uid as number : 0; - const movedChild = pendingMoves.get(newIndex); + const movedChild = movesWaitingForInsertionPoint.get(newIndex); if (movedChild) { keepCurrentSerializedChild = true; globalCommitContext.ops.push( @@ -189,7 +207,7 @@ function hydrateElementSlot( movedChild.instanceId, beforeChildId, ); - pendingInsertOrMovePatchCount -= 1; + insertOrMovePatchesWaitingForInsertionPoint -= 1; } else if (insertions[newIndex] !== undefined) { const insertedChild = insertions[newIndex]!; keepCurrentSerializedChild = true; @@ -201,7 +219,7 @@ function hydrateElementSlot( insertedChild.instanceId, beforeChildId, ); - pendingInsertOrMovePatchCount -= 1; + insertOrMovePatchesWaitingForInsertionPoint -= 1; } newIndex += 1; @@ -211,6 +229,7 @@ function hydrateElementSlot( oldIndex += 1; } } + return true; } function emitSerializedSubtreeRemove( @@ -232,7 +251,7 @@ function emitSerializedSubtreeRemove( removedSubtreeHandleIds, ); if (existing && !existing.parent) { - markRemovedSubtreeForCurrentCommit(existing); + markRemovedSubtreeForPostDispatchTeardown(existing); } } @@ -262,7 +281,7 @@ function emitCreateSubtree(node: BackgroundElementTemplateInstance): void { emitCreateSubtree(child); } } - node.prepareAttributeSlotsForNative(); + node.prepareAttributeSlotsForHydration(); node.emitCreate(); } @@ -271,9 +290,13 @@ function bindHydrationHandleId( handleId: number, templateKey: string, ): boolean { + const oldHandleId = instance.instanceId; try { backgroundElementTemplateInstanceManager.updateId(instance.instanceId, handleId); - instance.markCreateEmittedForHydration(); + // Ref proxies created before hydrate keep the old background id; resolve it + // lazily so user-held proxies continue selecting the hydrated native node. + hydrationMap.set(oldHandleId, handleId); + instance.markMaterializedByHydration(); return true; } catch (error) { if (__DEV__) { diff --git a/packages/react/runtime/src/element-template/background/hydration-listener.ts b/packages/react/runtime/src/element-template/background/hydration-listener.ts index df5f8f56f5..a8d789f0aa 100644 --- a/packages/react/runtime/src/element-template/background/hydration-listener.ts +++ b/packages/react/runtime/src/element-template/background/hydration-listener.ts @@ -4,7 +4,7 @@ import { globalCommitContext, resetGlobalCommitContext, - takeRemovedSubtreesForCurrentCommit, + takeRemovedSubtreesForPostDispatchTeardown, } from './commit-context.js'; import { markElementTemplateHydrated, @@ -16,7 +16,8 @@ import { BackgroundElementTemplateInstance } from './instance.js'; import { formatElementTemplateUpdateCommands, printElementTemplateTreeToString } from '../debug/alog.js'; import { profileEnd, profileStart } from '../debug/profile.js'; import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } from '../lynx/performance.js'; -import { flushPendingEvents } from '../prop-adapters/event.js'; +import { clearPendingEvents, flushPendingEvents } from '../prop-adapters/event.js'; +import { clearDelayedRefUiOps, clearPendingRefs, flushDelayedRefUiOps } from '../prop-adapters/ref.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; import type { SerializedElementTemplate } from '../protocol/types.js'; import { __root } from '../runtime/page/root-instance.js'; @@ -55,11 +56,15 @@ export function installElementTemplateHydrationListener(): void { } let after = root.firstChild; + let didHydrateMatchedInstances = true; for (const before of instances) { if (!after) { break; } - hydrateIntoContext(before, after); + if (!hydrateIntoContext(before, after)) { + didHydrateMatchedInstances = false; + break; + } after = after.nextSibling; } if (typeof __ALOG__ !== 'undefined' && __ALOG__) { @@ -74,9 +79,19 @@ export function installElementTemplateHydrationListener(): void { } markTiming('diffVdomEnd'); - markElementTemplateHydrated(); + if (didHydrateMatchedInstances) { + markElementTemplateHydrated(); + } else { + // Hydrate is not transactional; a later failure can happen after earlier + // nodes were rebound. Treat the pass as failed for externally observable + // work, so delayed refs/events are not released from an incomplete tree. + clearPendingEvents(); + clearPendingRefs(); + clearDelayedRefUiOps(); + resetGlobalCommitContext(); + } - const hasHydrateUpdate = globalCommitContext.ops.length > 0; + const hasHydrateUpdate = didHydrateMatchedInstances && globalCommitContext.ops.length > 0; let didDispatchHydrateUpdate = false; if (hasHydrateUpdate) { if (typeof __ALOG__ !== 'undefined' && __ALOG__) { @@ -93,7 +108,7 @@ export function installElementTemplateHydrationListener(): void { ), ); } - const removedSubtrees = takeRemovedSubtreesForCurrentCommit(); + const removedSubtreesAwaitingTeardown = takeRemovedSubtreesForPostDispatchTeardown(); try { lynx.getCoreContext().dispatchEvent({ type: ElementTemplateLifecycleConstant.update, @@ -105,12 +120,23 @@ export function installElementTemplateHydrationListener(): void { }); didDispatchHydrateUpdate = true; } finally { + if (!didDispatchHydrateUpdate) { + // Do not expose refs or replay delayed selector ops if the hydrate + // patch failed to reach the main thread; selectors may still point at + // stale pre-hydration ids in that case. + clearPendingEvents(); + clearPendingRefs(); + clearDelayedRefUiOps(); + } resetGlobalCommitContext(); - scheduleElementTemplateRemovedSubtreeCleanup(removedSubtrees); + scheduleElementTemplateRemovedSubtreeCleanup(removedSubtreesAwaitingTeardown); } } - if (!hasHydrateUpdate || didDispatchHydrateUpdate) { + if (didHydrateMatchedInstances && (!hasHydrateUpdate || didDispatchHydrateUpdate)) { flushPendingEvents(); + // Ordinary refs attach on Preact commit boundaries; hydration only releases + // delayed selector ops after ids have been rebound to stable native handles. + flushDelayedRefUiOps(); } }; diff --git a/packages/react/runtime/src/element-template/background/instance.ts b/packages/react/runtime/src/element-template/background/instance.ts index 66545b9b89..5d39db5f67 100644 --- a/packages/react/runtime/src/element-template/background/instance.ts +++ b/packages/react/runtime/src/element-template/background/instance.ts @@ -2,8 +2,8 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { prepareAttributeSlots as prepareRawAttributeSlots } from './attr-slots.js'; -import { globalCommitContext, markRemovedSubtreeForCurrentCommit } from './commit-context.js'; +import { prepareAttributeSlots as prepareRawAttributeSlots, queueRefAttributeSlotUpdates } from './attr-slots.js'; +import { globalCommitContext, markRemovedSubtreeForPostDispatchTeardown } from './commit-context.js'; import { isElementTemplateHydrated } from './commit-hook.js'; import { backgroundElementTemplateInstanceManager } from './manager.js'; import { isDirectOrDeepEqual } from '../../utils.js'; @@ -38,7 +38,7 @@ function syncElementSlotChildren( if (!parent || slotId < 0) { return; } - parent.elementSlots[slotId] = [...children]; + parent.elementSlots[slotId] = children; } export class BackgroundElementTemplateInstance { @@ -55,7 +55,7 @@ export class BackgroundElementTemplateInstance { public attributeSlots: SerializableValue[]; public elementSlots: BackgroundElementTemplateInstance[][] = []; private rawAttributeSlots: readonly unknown[] | undefined; - private hasEmittedCreate = false; + private isMaterializedOnMainThread = false; get parentNode(): BackgroundElementTemplateInstance | null { return this.parent; @@ -92,7 +92,7 @@ export class BackgroundElementTemplateInstance { } emitCreate(): void { - if (this.hasEmittedCreate) { + if (this.isMaterializedOnMainThread) { return; } if (this.instanceId === 0 && __DEV__) { @@ -108,17 +108,27 @@ export class BackgroundElementTemplateInstance { this.attributeSlots, this.elementSlots.map((children) => children.map((child) => child.instanceId)), ); - this.hasEmittedCreate = true; + this.isMaterializedOnMainThread = true; } - private isPendingCreate(): boolean { - return this.instanceId > 0 && !this.hasEmittedCreate; + private needsMainThreadCreate(): boolean { + return this.instanceId > 0 && !this.isMaterializedOnMainThread; } - private canEmitPatch(): boolean { + emitMainThreadCreateIfNeeded(): void { + if (!this.needsMainThreadCreate()) { + return; + } + // An unmaterialized subtree may receive attr updates before it is inserted; + // prepare here so ref attach happens once, at the create boundary. + this.prepareAttributeSlotsForNative(); + this.emitCreate(); + } + + private canEmitUpdatePatch(): boolean { // Background tree construction is local until hydrate binds it to main-thread - // instances. Only hydrated and already-created owners can emit update ops. - return isElementTemplateHydrated() && !this.isPendingCreate(); + // instances. Only hydrated and materialized owners can emit update ops. + return isElementTemplateHydrated() && !this.needsMainThreadCreate(); } // DOM API for Preact @@ -179,11 +189,11 @@ export class BackgroundElementTemplateInstance { return; } if (slotId !== -1 && parent) { - if (!parent.canEmitPatch()) { + if (!parent.canEmitUpdatePatch()) { return; } const beforeId = beforeChild ? beforeChild.instanceId : 0; - emitCreateRecursive(child); + emitMainThreadCreateRecursive(child); pushOp( ElementTemplateUpdateOps.insertNode, parent.instanceId, @@ -235,9 +245,14 @@ export class BackgroundElementTemplateInstance { return; } if (slotId !== -1 && parent) { - if (!parent.canEmitPatch()) { - if (child.isPendingCreate()) { - // A never-created subtree has no main-thread registry entry, so it + if (!parent.canEmitUpdatePatch()) { + if (!isElementTemplateHydrated()) { + // Pre-hydration commits have already exposed refs to user effects, so + // a local slot removal must detach them even though no native patch exists. + child.queueRefCleanupForSubtree(); + } + if (child.needsMainThreadCreate()) { + // An unmaterialized subtree has no main-thread registry entry, so it // can be released from the background manager without delayed cleanup. child.tearDown(); } @@ -250,9 +265,10 @@ export class BackgroundElementTemplateInstance { child.instanceId, collectElementTemplateSubtreeHandleIds(child), ); + child.queueRefCleanupForSubtree(); // The removed JS object graph may outlive the detach until GC, so keep // it pending and tear it down on the Snapshot-aligned delayed boundary. - markRemovedSubtreeForCurrentCommit(child); + markRemovedSubtreeForPostDispatchTeardown(child); } return; } @@ -260,6 +276,7 @@ export class BackgroundElementTemplateInstance { if (silent) { return; } + child.queueRefCleanupForSubtree(); } tearDown(): void { @@ -288,17 +305,29 @@ export class BackgroundElementTemplateInstance { } } + queueRefCleanupForSubtree(): void { + if (this.rawAttributeSlots) { + queueRefAttributeSlotUpdates(this.type, this.instanceId, this.rawAttributeSlots); + } + + let child = this.firstChild; + while (child) { + child.queueRefCleanupForSubtree(); + child = child.nextSibling; + } + } + getRawAttributeSlot(attrSlotIndex: number): unknown { return this.rawAttributeSlots?.[attrSlotIndex] ?? this.attributeSlots[attrSlotIndex]; } - markCreateEmittedForHydration(): void { + markMaterializedByHydration(): void { // Hydration binds this object to a template that already exists on the main - // thread; future updates must treat it as created without emitting create. - this.hasEmittedCreate = true; + // thread; future updates must treat it as materialized without emitting create. + this.isMaterializedOnMainThread = true; } - prepareAttributeSlotsForNative(): void { + prepareAttributeSlotsForNative(options?: { queueRefEffects?: boolean }): void { if (!this.rawAttributeSlots) { return; } @@ -306,20 +335,39 @@ export class BackgroundElementTemplateInstance { this.type, this.instanceId, this.rawAttributeSlots, + { + queueRefEffects: options?.queueRefEffects ?? true, + }, ); } + prepareAttributeSlotsForHydration(): void { + // Hydrate only rebinds the selector marker to the stable handle. The ref was + // already made visible to user effects on the pre-hydration commit path. + this.prepareAttributeSlotsForNative({ + queueRefEffects: false, + }); + } + setAttribute(key: string, value: unknown): void { if (isBuiltinRawTextTemplateKey(this.type) && (key === '0' || key === 'data')) { this.text = String(value); } else if (key === 'attributeSlots' && Array.isArray(value)) { const previousSlots = this.attributeSlots; + const previousRawSlots = this.rawAttributeSlots ?? previousSlots; const isHydrated = isElementTemplateHydrated(); - const canEmitPatch = isHydrated && !this.isPendingCreate(); + const canEmitUpdatePatch = isHydrated && !this.needsMainThreadCreate(); + // Pre-hydration commits must expose refs to effects, while post-hydration + // unmaterialized nodes defer ref attach to create emission to avoid dupes. + const shouldQueueRefEffects = !isHydrated || canEmitUpdatePatch; const nextSlots = prepareRawAttributeSlots( this.type, this.instanceId, value, + { + previousRawSlots, + queueRefEffects: shouldQueueRefEffects, + }, ); this.rawAttributeSlots = nextSlots === value ? undefined : value; const maxLength = Math.max(previousSlots.length, nextSlots.length); @@ -330,7 +378,7 @@ export class BackgroundElementTemplateInstance { if (isDirectOrDeepEqual(previousValue, nextValue)) { continue; } - if (!canEmitPatch) { + if (!canEmitUpdatePatch) { continue; } pushOp( @@ -365,7 +413,7 @@ export class BackgroundElementTemplateInstance { } this.rawAttributeSlots = undefined; this.attributeSlots = [text]; - if (!this.canEmitPatch()) { + if (!this.canEmitUpdatePatch()) { return; } pushOp(ElementTemplateUpdateOps.setAttribute, this.instanceId, 0, text); @@ -409,7 +457,7 @@ function collectElementTemplateSubtreeHandleIdsImpl( } } -function emitCreateRecursive(instance: BackgroundElementTemplateInstance): void { +function emitMainThreadCreateRecursive(instance: BackgroundElementTemplateInstance): void { if ( !isElementTemplateHydrated() || instance.instanceId < 0 @@ -423,10 +471,10 @@ function emitCreateRecursive(instance: BackgroundElementTemplateInstance): void continue; } for (const child of slotChildren) { - emitCreateRecursive(child); + emitMainThreadCreateRecursive(child); } } - instance.emitCreate(); + instance.emitMainThreadCreateIfNeeded(); } function collectChildren(slot: BackgroundElementTemplateSlot): BackgroundElementTemplateInstance[] { diff --git a/packages/react/runtime/src/element-template/hydration-map.ts b/packages/react/runtime/src/element-template/hydration-map.ts new file mode 100644 index 0000000000..ccdd34bddf --- /dev/null +++ b/packages/react/runtime/src/element-template/hydration-map.ts @@ -0,0 +1,13 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Maps pre-hydration background handle ids to stable hydrated handle ids. + */ +const hydrationMap: Map = /*#__PURE__*/ new Map(); + +/** + * @internal + */ +export { hydrationMap }; diff --git a/packages/react/runtime/src/element-template/internal.ts b/packages/react/runtime/src/element-template/internal.ts index 90bd6e48cc..2934a4925c 100644 --- a/packages/react/runtime/src/element-template/internal.ts +++ b/packages/react/runtime/src/element-template/internal.ts @@ -54,4 +54,9 @@ export type { Options } from 'preact'; // export { registerWorkletOnBackground } from '../worklet/hmr.js'; // export { loadWorkletRuntime } from '@lynx-js/react/worklet-runtime/bindings'; export { __etSlot } from './runtime/components/slot.js'; -export { __etAttrPlanMap, adaptEventAttrSlot, adaptSpreadAttrSlot } from './runtime/template/attr-slot-plan.js'; +export { + __etAttrPlanMap, + adaptEventAttrSlot, + adaptRefAttrSlot, + adaptSpreadAttrSlot, +} from './runtime/template/attr-slot-plan.js'; diff --git a/packages/react/runtime/src/element-template/prop-adapters/event.ts b/packages/react/runtime/src/element-template/prop-adapters/event.ts index f847d7b958..e0fb799e80 100644 --- a/packages/react/runtime/src/element-template/prop-adapters/event.ts +++ b/packages/react/runtime/src/element-template/prop-adapters/event.ts @@ -40,10 +40,14 @@ function dispatchEvent(eventValue: string, data: EventDataType): boolean { } export function clearEventState(): void { - pendingEvents.length = 0; + clearPendingEvents(); queuePendingEvents = false; } +export function clearPendingEvents(): void { + pendingEvents.length = 0; +} + export function resetEventStateForRuntime(): void { clearEventState(); queuePendingEvents = true; diff --git a/packages/react/runtime/src/element-template/prop-adapters/ref.ts b/packages/react/runtime/src/element-template/prop-adapters/ref.ts new file mode 100644 index 0000000000..26ffed65c9 --- /dev/null +++ b/packages/react/runtime/src/element-template/prop-adapters/ref.ts @@ -0,0 +1,148 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { OrdinaryRefEffectQueue, SelectorRefProxy, normalizeRefValue } from '../../core/ref.js'; +import type { OrdinaryRef, RefProxyForwardedMethods } from '../../core/ref.js'; +import { hydrationMap } from '../hydration-map.js'; +import type { SerializableValue } from '../protocol/types.js'; + +export type EtRef = OrdinaryRef; + +type RefToken = [handleId: number, attrSlotIndex: number]; + +const refEffectQueue = /*#__PURE__*/ new OrdinaryRefEffectQueue(); +const delayedRefUiOps: (() => void)[] = []; +let shouldDelayRefUiOps = true; + +function resolveRefHandleId(handleId: number): number { + // The proxy may have been handed to user code before hydrate; resolve the id + // at execution time so the same object follows the hydrated native handle. + return hydrationMap.get(handleId) ?? handleId; +} + +function runOrDelayRefUiOp(task: () => void): void { + if (!shouldDelayRefUiOps) { + task(); + return; + } + delayedRefUiOps.push(task); +} + +export function getRefValue(handleId: number, attrSlotIndex: number): string { + return `${handleId}-${attrSlotIndex}`; +} + +export function flushDelayedRefUiOps(): void { + const tasks = delayedRefUiOps.splice(0); + shouldDelayRefUiOps = false; + + for (const task of tasks) { + task(); + } +} + +export function clearDelayedRefUiOps(): void { + delayedRefUiOps.length = 0; +} + +export class ElementTemplateRefProxy extends SelectorRefProxy { + constructor( + private readonly handleId: number, + private readonly attrSlotIndex: number, + ) { + super(); + return this.createProxy(); + } + + protected createProxyTarget(): ElementTemplateRefProxy { + return new ElementTemplateRefProxy(this.handleId, this.attrSlotIndex); + } + + protected runOrDelay(task: () => void): void { + runOrDelayRefUiOp(task); + } + + get selector(): string { + return `[ref=${getRefValue(resolveRefHandleId(this.handleId), this.attrSlotIndex)}]`; + } +} + +export interface ElementTemplateRefProxy extends RefProxyForwardedMethods {} + +export function getRefFromValue(value: unknown): EtRef | null { + return normalizeRefValue(value) ?? null; +} + +function hasOwnRef(value: unknown): value is { ref?: unknown } { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.prototype.hasOwnProperty.call(value, 'ref'); +} + +export function getSpreadRefFromValue(value: unknown): EtRef | null | undefined { + if (!hasOwnRef(value)) { + return undefined; + } + return getRefFromValue(value.ref); +} + +export function prepareRefAttrSlot( + handleId: number, + attrSlotIndex: number, + value: unknown, +): SerializableValue | null { + if (__LEPUS__ && value === 1) { + // The LEPUS transform cannot carry the background ref callback/object into + // first-screen create, so direct refs arrive here as an internal presence + // marker. Background JS still validates the real user ref before attaching. + return getRefValue(handleId, attrSlotIndex); + } + if (getRefFromValue(value) === null) { + return null; + } + return getRefValue(handleId, attrSlotIndex); +} + +export function prepareSpreadRefAttrValue( + handleId: number, + attrSlotIndex: number, + value: unknown, +): SerializableValue | null | undefined { + const ref = getSpreadRefFromValue(value); + if (ref === undefined) { + return undefined; + } + return ref === null ? null : getRefValue(handleId, attrSlotIndex); +} + +export function queueRefAttrUpdate( + oldValue: unknown, + newValue: unknown, + handleId: number, + attrSlotIndex: number, +): void { + const oldRef = getRefFromValue(oldValue); + const newRef = getRefFromValue(newValue); + refEffectQueue.queue(oldRef, newRef, [handleId, attrSlotIndex]); +} + +export function flushPendingRefs(): void { + refEffectQueue.flush(([handleId, attrSlotIndex]) => new ElementTemplateRefProxy(handleId, attrSlotIndex)); +} + +export function clearPendingRefs(): void { + refEffectQueue.clear(); +} + +export function hasPendingRefs(): boolean { + return refEffectQueue.hasPending(); +} + +export function clearRefState(): void { + clearPendingRefs(); + hydrationMap.clear(); + clearDelayedRefUiOps(); + shouldDelayRefUiOps = true; +} diff --git a/packages/react/runtime/src/element-template/prop-adapters/spread.ts b/packages/react/runtime/src/element-template/prop-adapters/spread.ts index ce4739236e..0e08369029 100644 --- a/packages/react/runtime/src/element-template/prop-adapters/spread.ts +++ b/packages/react/runtime/src/element-template/prop-adapters/spread.ts @@ -3,6 +3,7 @@ // LICENSE file in the root directory of this source tree. import { getEventValue } from './event-value.js'; +import { prepareSpreadRefAttrValue } from './ref.js'; import type { SerializableValue } from '../protocol/types.js'; export interface SpreadAttrAdapterContext { @@ -43,6 +44,14 @@ export function prepareSpreadAttrSlot( continue; } + if (key === 'ref') { + const refValue = prepareSpreadRefAttrValue(handleId, attrSlotIndex, value); + if (refValue !== undefined) { + prepared['ref'] = refValue; + } + continue; + } + if (isEventPropKey(key)) { prepared[key] = spreadValue === null || spreadValue === undefined || spreadValue === false ? null @@ -52,7 +61,6 @@ export function prepareSpreadAttrSlot( if ( spreadValue === undefined - || key === 'ref' || key.endsWith(':ref') || key.endsWith(':gesture') || namespacedEventKeyRegExp.test(key) diff --git a/packages/react/runtime/src/element-template/runtime/template/attr-slot-plan.ts b/packages/react/runtime/src/element-template/runtime/template/attr-slot-plan.ts index 0532142833..727ee2f123 100644 --- a/packages/react/runtime/src/element-template/runtime/template/attr-slot-plan.ts +++ b/packages/react/runtime/src/element-template/runtime/template/attr-slot-plan.ts @@ -3,6 +3,7 @@ // LICENSE file in the root directory of this source tree. import { getEventValue } from '../../prop-adapters/event-value.js'; +import { prepareRefAttrSlot } from '../../prop-adapters/ref.js'; import { prepareSpreadAttrSlot } from '../../prop-adapters/spread.js'; import type { SpreadAttrAdapterContext } from '../../prop-adapters/spread.js'; import type { SerializableValue } from '../../protocol/types.js'; @@ -37,6 +38,15 @@ export function adaptEventAttrSlot( return getEventValue(handleId, attrSlotIndex); } +export function adaptRefAttrSlot( + handleId: number, + attrSlotIndex: number, + value: unknown, + _context?: EtAttrAdapterContext, +): SerializableValue | null { + return prepareRefAttrSlot(handleId, attrSlotIndex, value); +} + export function adaptSpreadAttrSlot( handleId: number, attrSlotIndex: number, diff --git a/packages/react/runtime/src/element-template/runtime/template/registry.ts b/packages/react/runtime/src/element-template/runtime/template/registry.ts index 900b1fa14e..5a67c1251b 100644 --- a/packages/react/runtime/src/element-template/runtime/template/registry.ts +++ b/packages/react/runtime/src/element-template/runtime/template/registry.ts @@ -10,7 +10,7 @@ // Other IDs (e.g. positive IDs coming from background-created nodes) fall back to a Map. const negativeRefs: Array = []; -const otherRefs: Map = new Map(); +const otherRefs: Map = /*#__PURE__*/ new Map(); export function setElementTemplateNativeRef(id: number, nativeRef: ElementRef): void { if (id < 0) { diff --git a/packages/react/runtime/src/snapshot/debug/vnodeSource.ts b/packages/react/runtime/src/snapshot/debug/vnodeSource.ts index d1231573a2..4ae2cf2bec 100644 --- a/packages/react/runtime/src/snapshot/debug/vnodeSource.ts +++ b/packages/react/runtime/src/snapshot/debug/vnodeSource.ts @@ -17,7 +17,7 @@ interface PatchedVNode extends VNode { [DOM]?: { __id?: number } | null; } -const snapshotVNodeSourceMap: Map = new Map(); +const snapshotVNodeSourceMap: Map = /*#__PURE__*/ new Map(); let hookInstalled = false; diff --git a/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts b/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts index 5f5ccba093..f2199a8e57 100644 --- a/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts +++ b/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts @@ -2,18 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { NodesRef, SelectorQuery } from '@lynx-js/types'; - +import { SelectorRefProxy } from '../../../core/ref.js'; +import type { RefProxyForwardedMethods } from '../../../core/ref.js'; import { hydrationMap } from '../../snapshot/snapshotInstanceHydrationMap.js'; -type FunctionPropertyNames = { - [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never; -}[keyof T]; - -type ForwardableNodesRefMethod = Exclude, 'exec'>; - -type RefTask = (nodesRef: NodesRef) => SelectorQuery; - /** * A flag to indicate whether UI operations should be delayed. * When set to true, UI operations will be queued in the `delayedUiOps` array @@ -58,64 +50,31 @@ function runDelayedUiOps(): void { * A proxy class designed for managing and executing reference-based tasks. * It delays the execution of tasks until hydration is complete. */ -class RefProxy { +class RefProxy extends SelectorRefProxy { private readonly refAttr: [snapshotInstanceId: number, expIndex: number]; - private task: RefTask | undefined; constructor(refAttr: [snapshotInstanceId: number, expIndex: number]) { + super(); this.refAttr = refAttr; - this.task = undefined; - - return new Proxy(this, { - get: (target, prop, receiver) => { - if ( - typeof prop === 'symbol' - || prop === 'then' - || prop in target - || typeof prop !== 'string' - ) { - return Reflect.get(target, prop, receiver); - } - const forward = (method: K) => { - return (...args: Parameters) => { - return new RefProxy(target.refAttr).setTask(method, args); - }; - }; + return this.createProxy(); + } - return forward(prop as ForwardableNodesRefMethod); - }, - }) as RefProxy; + protected createProxyTarget(): RefProxy { + return new RefProxy(this.refAttr); } - private setTask( - method: K, - args: Parameters, - ): this { - this.task = (nodesRef) => { - const nodesRefMethod = nodesRef[method] as (...params: Parameters) => SelectorQuery; - return nodesRefMethod.apply(nodesRef, args); - }; - return this; + protected runOrDelay(task: () => void): void { + runOrDelay(task); } get selector(): string { const realRefId = hydrationMap.get(this.refAttr[0]) ?? this.refAttr[0]; return `[react-ref-${realRefId}-${this.refAttr[1]}]`; } - - exec(): void { - runOrDelay(() => { - this.task!(lynx.createSelectorQuery().select(this.selector)).exec(); - }); - } } -type RefProxyForwardedMethods = { - [K in ForwardableNodesRefMethod]: (...args: Parameters) => RefProxy; -}; - -interface RefProxy extends RefProxyForwardedMethods {} +interface RefProxy extends RefProxyForwardedMethods {} /** * @internal diff --git a/packages/react/runtime/src/snapshot/list/list.ts b/packages/react/runtime/src/snapshot/list/list.ts index ffce07d081..d47995e2e5 100644 --- a/packages/react/runtime/src/snapshot/list/list.ts +++ b/packages/react/runtime/src/snapshot/list/list.ts @@ -8,7 +8,7 @@ import { applyRefQueue } from '../snapshot/workletRef.js'; export const gSignMap: Record> = {}; export const gRecycleMap: Record>> = {}; -const gParentWeakMap: WeakMap = new WeakMap(); +const gParentWeakMap: WeakMap = /*#__PURE__*/ new WeakMap(); const resolvedPromise = /* @__PURE__ */ Promise.resolve(); export function clearListGlobal(): void { diff --git a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts index a457a41e5e..c2cfcdeee2 100644 --- a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts +++ b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts @@ -14,7 +14,7 @@ import { createRuntimeSnapshot, snapshotManager } from './definition.js'; import type { Snapshot } from './definition.js'; import { DynamicPartType } from './dynamicPartType.js'; import { reconstructInstanceTree } from './reconstructInstanceTree.js'; -import { applyRef, clearQueuedRefs, getRefFromValue, queueRefAttrUpdate } from './ref.js'; +import { clearQueuedRefs, clearRef, getRefFromValue, queueRefAttrUpdate } from './ref.js'; import type { Ref } from './ref.js'; import { snapshotCreatorMap } from './snapshot.js'; import { hydrationMap } from './snapshotInstanceHydrationMap.js'; @@ -300,9 +300,9 @@ export class BackgroundSnapshotInstance { const value = v.__values![i]; if (value && (typeof value === 'object' || typeof value === 'function')) { if ('__spread' in value && 'ref' in value && value.ref) { - applyRef(value.ref as Ref, null); + clearRef(value.ref as Ref); } else if ('__ref' in value) { - applyRef(value as Ref, null); + clearRef(value as Ref); } } }); @@ -439,7 +439,7 @@ export class BackgroundSnapshotInstance { }; } if ('__ref' in newValueObj) { - queueRefAttrUpdate(oldValue as Ref, newValueObj as Ref, this.__id, index); + queueRefAttrUpdate(oldValue as Ref, newValueObj as unknown as Ref, this.__id, index); return { needUpdate: false, valueToCommit: 1 }; } if ('_wkltId' in newValueObj) { diff --git a/packages/react/runtime/src/snapshot/snapshot/list.ts b/packages/react/runtime/src/snapshot/snapshot/list.ts index 426f80b2e9..bcd0a84774 100644 --- a/packages/react/runtime/src/snapshot/snapshot/list.ts +++ b/packages/react/runtime/src/snapshot/snapshot/list.ts @@ -6,7 +6,7 @@ import { componentAtIndexFactory, enqueueComponentFactory, gRecycleMap, gSignMap import { hydrate } from '../renderToOpcodes/hydrate.js'; import type { SnapshotInstance } from '../snapshot/snapshot.js'; -const destroyLifetimeHandlerMap = new Map void>(); +const destroyLifetimeHandlerMap = /*#__PURE__*/ new Map void>(); export function snapshotCreateList( pageId: number, diff --git a/packages/react/runtime/src/snapshot/snapshot/ref.ts b/packages/react/runtime/src/snapshot/snapshot/ref.ts index 25bcb0b39c..206c1320fc 100644 --- a/packages/react/runtime/src/snapshot/snapshot/ref.ts +++ b/packages/react/runtime/src/snapshot/snapshot/ref.ts @@ -4,14 +4,16 @@ import type { Element, Worklet, WorkletRefImpl } from '@lynx-js/react/worklet-runtime/bindings'; import { workletUnRef } from './workletRef.js'; +import { OrdinaryRefEffectQueue, applyOrdinaryRef, normalizeRefValue } from '../../core/ref.js'; +import type { OrdinaryRef } from '../../core/ref.js'; import { RefProxy } from '../lifecycle/ref/delay.js'; import type { SnapshotInstance } from '../snapshot/snapshot.js'; -const refsToClear: Ref[] = []; -const refsToApply: (Ref | [snapshotInstanceId: number, expIndex: number])[] = []; +type RefToken = [snapshotInstanceId: number, expIndex: number]; -type Ref = (((ref: RefProxy) => (() => void) | void) | { current: RefProxy | null }) & { - _unmount?: (() => void) | void; +const refEffectQueue = /*#__PURE__*/ new OrdinaryRefEffectQueue(); + +type Ref = OrdinaryRef & { __ref?: { value: number }; }; @@ -30,29 +32,8 @@ function unref(snapshot: SnapshotInstance, recursive: boolean): void { } } -// This function is modified from preact source code. -function applyRef(ref: Ref, value: null | [snapshotInstanceId: number, expIndex: number]): void { - const newRef = value && new RefProxy(value); - - try { - if (typeof ref == 'function') { - const hasRefUnmount = typeof ref._unmount == 'function'; - if (hasRefUnmount) { - ref._unmount!(); - } - - if (!hasRefUnmount || newRef != null) { - // Store the cleanup function on the function - // instance object itself to avoid shape - // transitioning vnode - ref._unmount = ref(newRef!); - } - } else ref.current = newRef; - /* v8 ignore start */ - } catch (e) { - lynx.reportError(e as Error); - } - /* v8 ignore stop */ +function clearRef(ref: Ref): void { + applyOrdinaryRef(ref, null); } function updateRef( @@ -94,34 +75,21 @@ function getRefFromValue(val: unknown): Ref | null { } function transformRef(ref: unknown): Ref | null | undefined { - if (ref === undefined || ref === null) { - return ref; + const validRef = normalizeRefValue(ref); + if (validRef === undefined || validRef === null) { + return validRef; } - if (typeof ref === 'function' || (typeof ref === 'object' && 'current' in ref)) { - if ('__ref' in ref) { - return ref as Ref; - } - return Object.defineProperty(ref, '__ref', { value: 1 }) as Ref; + if ('__ref' in validRef) { + return validRef as Ref; } - throw new Error( - `Elements' "ref" property should be a function, or an object created ` - + `by createRef(), but got [${typeof ref}] instead`, - ); + return Object.defineProperty(validRef, '__ref', { value: 1 }) as Ref; } function applyQueuedRefs(): void { - try { - for (const ref of refsToClear) { - applyRef(ref, null); - } - for (let i = 0; i < refsToApply.length; i += 2) { - const ref = refsToApply[i] as Ref; - const value = refsToApply[i + 1] as [snapshotInstanceId: number, expIndex: number] | null; - applyRef(ref, value); - } - } finally { - clearQueuedRefs(); + if (!refEffectQueue.hasPending()) { + return; } + refEffectQueue.flush(value => new RefProxy(value)); } function queueRefAttrUpdate( @@ -130,20 +98,11 @@ function queueRefAttrUpdate( snapshotInstanceId: number, expIndex: number, ): void { - if (oldRef === newRef) { - return; - } - if (oldRef) { - refsToClear.push(oldRef); - } - if (newRef) { - refsToApply.push(newRef, [snapshotInstanceId, expIndex]); - } + refEffectQueue.queue(oldRef, newRef, [snapshotInstanceId, expIndex]); } function clearQueuedRefs(): void { - refsToClear.length = 0; - refsToApply.length = 0; + refEffectQueue.clear(); } /** @@ -154,7 +113,7 @@ export { updateRef, unref, transformRef, - applyRef, + clearRef, applyQueuedRefs, clearQueuedRefs, getRefFromValue, diff --git a/packages/react/runtime/src/snapshot/snapshot/snapshotInstanceHydrationMap.ts b/packages/react/runtime/src/snapshot/snapshot/snapshotInstanceHydrationMap.ts index d89239486d..ac3c130bdc 100644 --- a/packages/react/runtime/src/snapshot/snapshot/snapshotInstanceHydrationMap.ts +++ b/packages/react/runtime/src/snapshot/snapshot/snapshotInstanceHydrationMap.ts @@ -9,7 +9,7 @@ * The map is used by the ref system to translate between snapshot instance IDs when * operations need to cross the thread boundary during the commit phase. */ -const hydrationMap: Map = new Map(); +const hydrationMap: Map = /*#__PURE__*/ new Map(); /** * @internal diff --git a/packages/react/runtime/src/worklet-runtime/workletRuntime.ts b/packages/react/runtime/src/worklet-runtime/workletRuntime.ts index 8f51a106c0..b077eeb937 100644 --- a/packages/react/runtime/src/worklet-runtime/workletRuntime.ts +++ b/packages/react/runtime/src/worklet-runtime/workletRuntime.ts @@ -105,7 +105,7 @@ function validateWorklet(ctx: unknown): ctx is Worklet { return typeof ctx === 'object' && ctx !== null && ('_wkltId' in ctx || '_lepusWorkletHash' in ctx); } -const workletCache = new WeakMap unknown)>(); +const workletCache = /*#__PURE__*/ new WeakMap unknown)>(); function transformWorklet(ctx: Worklet, isWorklet: true): (...args: unknown[]) => unknown; function transformWorklet( diff --git a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs index 3c7044c839..1dbf4dcb90 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs @@ -63,12 +63,15 @@ impl AttrName { pub fn from_ns(ns: Ident, name: Ident) -> Self { let ns_str = ns.sym.as_ref(); let name_str = name.sym.as_ref(); + if name_str == "ref" { + return AttrName::WorkletRef; + } + if ns_str != "main-thread" { return AttrName::Attr; } match name_str { - "ref" => AttrName::WorkletRef, "gesture" => AttrName::Gesture, _ if get_event_type_and_name(name_str).is_some() => AttrName::WorkletEvent, _ => AttrName::Attr, diff --git a/packages/react/transform/crates/swc_plugin_element_template/lib.rs b/packages/react/transform/crates/swc_plugin_element_template/lib.rs index 7df29460d4..c476b11447 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/lib.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/lib.rs @@ -300,6 +300,7 @@ where #[derive(Clone, Copy)] enum AttrPlanAdapter { Event, + Ref, Spread, } @@ -311,6 +312,11 @@ where slot_index, .. } => Some((*slot_index, AttrPlanAdapter::Event)), + DynamicAttributePart::Attr { + attr_name: AttrName::Ref, + slot_index, + .. + } => Some((*slot_index, AttrPlanAdapter::Ref)), DynamicAttributePart::Spread { slot_index, .. } => { Some((*slot_index, AttrPlanAdapter::Spread)) } @@ -354,6 +360,10 @@ where "$internal_runtime_id.adaptEventAttrSlot" as Expr, internal_runtime_id: Expr = internal_runtime_id.clone(), ), + AttrPlanAdapter::Ref => quote!( + "$internal_runtime_id.adaptRefAttrSlot" as Expr, + internal_runtime_id: Expr = internal_runtime_id.clone(), + ), AttrPlanAdapter::Spread => quote!( "$internal_runtime_id.adaptSpreadAttrSlot" as Expr, internal_runtime_id: Expr = internal_runtime_id.clone(), diff --git a/packages/react/transform/crates/swc_plugin_element_template/lowering.rs b/packages/react/transform/crates/swc_plugin_element_template/lowering.rs index f797336b8f..2e8acc1bd3 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/lowering.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/lowering.rs @@ -55,7 +55,13 @@ where value } } else if let AttrName::Ref = attr_name { - value + if target == TransformTarget::LEPUS { + quote!("1" as Expr) + } else { + value + } + } else if let AttrName::WorkletRef = attr_name { + quote!("null" as Expr) } else { value }; diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap index 0c7ac23356..48734dfed8 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap @@ -3,7 +3,7 @@ source: packages/react/transform/crates/swc_plugin_element_template/tests/elemen expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" --- { - "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n viewRef\n]}/>;\n", + "code": "import * as ReactLynxInternal from \"@lynx-js/react/internal\";\nimport * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\nReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1] = [\n 0,\n ReactLynxInternal.adaptRefAttrSlot\n];\n<_et_da39a_test_1 attributeSlots={[\n viewRef\n]}/>;\n", "templates": [ { "template_id": "_et_da39a_test_1", diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap index 0c7ac23356..e350ececd3 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap @@ -3,7 +3,7 @@ source: packages/react/transform/crates/swc_plugin_element_template/tests/elemen expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" --- { - "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n viewRef\n]}/>;\n", + "code": "import * as ReactLynxInternal from \"@lynx-js/react/internal\";\nimport * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\nReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1] = [\n 0,\n ReactLynxInternal.adaptRefAttrSlot\n];\n<_et_da39a_test_1 attributeSlots={[\n 1\n]}/>;\n", "templates": [ { "template_id": "_et_da39a_test_1", diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap index 7bee55e13c..dbce1cfe82 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap @@ -1,10 +1,9 @@ --- source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs -assertion_line: 612 expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" --- { - "code": "import * as ReactLynxInternal from \"@lynx-js/react/internal\";\nimport * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\nReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1] = [\n 1,\n ReactLynxInternal.adaptSpreadAttrSlot,\n 2,\n ReactLynxInternal.adaptEventAttrSlot\n];\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n props,\n 1,\n viewRef\n]}/>;\n", + "code": "import * as ReactLynxInternal from \"@lynx-js/react/internal\";\nimport * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\nReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1] = [\n 1,\n ReactLynxInternal.adaptSpreadAttrSlot,\n 2,\n ReactLynxInternal.adaptEventAttrSlot,\n 3,\n ReactLynxInternal.adaptRefAttrSlot\n];\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n props,\n 1,\n 1\n]}/>;\n", "templates": [ { "template_id": "_et_da39a_test_1", diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs index c83b2722d3..925a422d4e 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs @@ -655,6 +655,7 @@ fn should_not_use_snapshot_ref_transform_in_element_template_mode() { TransformMode::Development, ); + assert!(code.contains(r#"require("@custom/react/internal").adaptRefAttrSlot"#)); assert!(code.contains("viewRef")); assert!(!code.contains("transformRef")); assert!(!code.contains("@lynx-js/react/internal")); diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs index bc844b902d..86b293030f 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs @@ -183,7 +183,7 @@ fn should_emit_direct_event_attr_plan_for_lepus_target() { } #[test] -fn should_emit_spread_attr_plan_without_ref_adapter() { +fn should_emit_spread_attr_plan_with_ref_adapter() { let (code, _) = first_user_template_json_with_code( r#" @@ -197,13 +197,13 @@ fn should_emit_spread_attr_plan_without_ref_adapter() { assert!( code.contains( - "ReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1]=[2,ReactLynxInternal.adaptSpreadAttrSlot];" + "ReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1]=[1,ReactLynxInternal.adaptRefAttrSlot,2,ReactLynxInternal.adaptSpreadAttrSlot];" ), - "spread slots should register the ET spread attr adapter, got: {code}" + "ref and spread slots should register ET attr adapters, got: {code}" ); assert!( !code.contains("adaptEventAttrSlot"), - "ref and ordinary attrs must not enter the event adapter plan, got: {code}" + "ref, spread, and ordinary attrs must not enter the event adapter plan, got: {code}" ); } @@ -432,9 +432,9 @@ fn should_keep_slot_descriptor_order_for_dynamic_attr_spread_event_and_ref() { let code = without_whitespace(&code); assert!( code.contains( - "ReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1]=[1,ReactLynxInternal.adaptSpreadAttrSlot,2,ReactLynxInternal.adaptEventAttrSlot];" + "ReactLynxInternal.__etAttrPlanMap[_et_da39a_test_1]=[1,ReactLynxInternal.adaptSpreadAttrSlot,2,ReactLynxInternal.adaptEventAttrSlot,3,ReactLynxInternal.adaptRefAttrSlot];" ), - "spread and direct event adapters should keep their descriptor slot order, got: {code}" + "spread, direct event, and ref adapters should keep their descriptor slot order, got: {code}" ); let attrs = template["attributesArray"] @@ -460,10 +460,20 @@ fn should_keep_slot_descriptor_order_for_dynamic_attr_spread_event_and_ref() { #[test] fn should_keep_worklet_attr_descriptor_keys_for_namespaced_attrs() { - let template = first_user_template_json( + let (code, template) = first_user_template_json_with_code( r#" "#, + element_template_config(), + ); + let code = without_whitespace(&code); + assert!( + !code.contains("adaptRefAttrSlot"), + "main-thread:ref must not be lowered as an ordinary ET ref adapter, got: {code}" + ); + assert!( + !code.contains("viewRef"), + "unsupported namespaced ref must not leak the raw ref value, got: {code}" ); let attrs = template["attributesArray"]