Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-et-ref-adapter.md
Original file line number Diff line number Diff line change
@@ -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.
184 changes: 184 additions & 0 deletions packages/react/runtime/__test__/core/ref.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestSelectorRefProxy> {
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<TestSelectorRefProxy> {}

function stubReportError(): ReturnType<typeof vi.fn> {
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<string, string>();
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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', []);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
interface AppProps {
hostRef?: unknown;
}

export function App({ hostRef }: AppProps) {
return <view ref={hostRef}>direct</view>;
}
Original file line number Diff line number Diff line change
@@ -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 (
<view>
<view ref={directRef}>direct</view>
<view ref={objectRef}>object</view>
<view {...spread}>spread</view>
</view>
);
}
Original file line number Diff line number Diff line change
@@ -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 <view {...spread}>spread</view>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
interface AppProps {
mainThreadRef?: unknown;
workletRef?: unknown;
}

export function App({ mainThreadRef, workletRef }: AppProps) {
return (
<view main-thread:ref={mainThreadRef} worklet:ref={workletRef}>
unsupported
</view>
);
}
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
Loading
Loading