Skip to content
Open
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
92 changes: 92 additions & 0 deletions src/__tests__/native/reactivity-dual-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Effect } from "../../native/reactivity";

type ReactivityModule = typeof import("../../native/reactivity");

// A fresh module registry evaluates the module again against the same globalThis, as a second build in one app does
function loadCopy(): ReactivityModule {
jest.resetModules();
return jest.requireActual<ReactivityModule>("../../native/reactivity");
}

function createSubscriber(): Effect & { runs: number } {
const subscriber: Effect & { runs: number } = {
observers: new Set(),
runs: 0,
run: () => {
subscriber.runs++;
},
};

return subscriber;
}

describe("two evaluations of the reactivity module", () => {
test("are two copies", () => {
const first = loadCopy();
const second = loadCopy();

expect(second.observable).not.toBe(first.observable);
});

test("share every piece of process-global state", () => {
const first = loadCopy();
const second = loadCopy();
const census = Object.keys(globalThis.__react_native_css_reactivity ?? {});

expect(census.length).toBeGreaterThan(0);

for (const name of census) {
const key = name as keyof ReactivityModule;

expect(first[key]).toBeDefined();
expect(second[key]).toBe(first[key]);
}
});

test("deliver a write through one to a subscriber on the other", () => {
const first = loadCopy();
const second = loadCopy();
const subscriber = createSubscriber();
const next =
second.colorScheme.get(subscriber) === "dark" ? "light" : "dark";

first.colorScheme.set(next);

expect(subscriber.runs).toBe(1);
expect(second.colorScheme.get()).toBe(next);
});

test("batch a write through one inside a batch the other opened", () => {
const first = loadCopy();
const second = loadCopy();
const subscriber = createSubscriber();
const value = second.observable(0);
value.get(subscriber);

first.observableBatch.current = new Set<Effect>();
value.set(1);

expect(subscriber.runs).toBe(0);
expect(first.observableBatch.current.size).toBe(1);

for (const effect of first.observableBatch.current) {
effect.run();
}
first.observableBatch.current = undefined;

expect(subscriber.runs).toBe(1);
});

test("key a component's interaction and layout observables off one family", () => {
const first = loadCopy();
const second = loadCopy();
const key = {};

expect(second.containerLayoutFamily(key)).toBe(
first.containerLayoutFamily(key),
);
expect(second.hoverFamily(key)).toBe(first.hoverFamily(key));
expect(second.activeFamily(key)).toBe(first.activeFamily(key));
expect(second.focusFamily(key)).toBe(first.focusFamily(key));
});
});
143 changes: 87 additions & 56 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export type Observable<Value, Arg = Value> = {
type Read<Value, Arg> = (get: Getter, arg?: Arg) => Value;
export type Getter = <Value>(observable: Observable<Value, any>) => Value;

export const observableBatch: {
export type ObservableBatch = {
current?: Set<Effect>;
} = {};
};

export function observable<Value, Arg = Value>(
init: Value | Read<Value, Arg>,
Expand Down Expand Up @@ -198,42 +198,7 @@ export type VariableContextValue = Record<string, StyleDescriptor> & {
[VAR_SYMBOL]: true;
};

/** Pseudo Classes ************************************************************/

export const hoverFamily = weakFamily(() => observable(false));
export const activeFamily = weakFamily(() => observable<boolean>(false));
export const focusFamily = weakFamily(() => observable<boolean>(false));

/** Dimensions ****************************************************************/

export const dimensions = observable(Dimensions.get("window"));
export const vw = observable<number>(
(read, value) => value ?? read(dimensions)?.width,
);
export const vh = observable<number>(
(read, value) => value ?? read(dimensions)?.height,
);

Dimensions.addEventListener("change", ({ window }) => {
observableBatch.current = new Set();
vw.set(window.width);
vh.set(window.height);

for (const effect of observableBatch.current) {
effect.run();
}

observableBatch.current = undefined;
});

/** Color Scheme **************************************************************/

export const colorScheme = observable<ColorSchemeName | null | undefined>(
Appearance.getColorScheme(),
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));

/** Containers ****************************************************************/
/******************************* Process globals ******************************/

export type ContainerContextValue = Record<
string,
Expand All @@ -242,25 +207,91 @@ export type ContainerContextValue = Record<
props: Record<string, unknown> | null | undefined;
}
>;
export const ContainerContext = createContext<ContainerContextValue>({});

export const containerLayoutFamily = weakFamily(() => {
return observable<LayoutRectangle>({
x: 0,
y: 0,
width: 0,
height: 0,
});
});

export const containerWidthFamily = weakFamily((key) => {
return observable((read) => {
return read(containerLayoutFamily(key))?.width || 0;
// The exports map sends import and require to two builds, so one app can evaluate this module twice; one pinned object keeps a single copy of every observable, context and listener
function createReactivityState() {
const observableBatch: ObservableBatch = {};

const dimensions = observable(Dimensions.get("window"));
const vw = observable<number>(
(read, value) => value ?? read(dimensions)?.width,
);
const vh = observable<number>(
(read, value) => value ?? read(dimensions)?.height,
);

Dimensions.addEventListener("change", ({ window }) => {
observableBatch.current = new Set();
vw.set(window.width);
vh.set(window.height);

for (const effect of observableBatch.current) {
effect.run();
}

observableBatch.current = undefined;
});
});

export const containerHeightFamily = weakFamily((key) => {
return observable((read) => {
return read(containerLayoutFamily(key))?.height || 0;
const colorScheme = observable<ColorSchemeName | null | undefined>(
Appearance.getColorScheme(),
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));

const containerLayoutFamily = weakFamily(() => {
return observable<LayoutRectangle>({
x: 0,
y: 0,
width: 0,
height: 0,
});
});
});

return {
observableBatch,

hoverFamily: weakFamily(() => observable(false)),
activeFamily: weakFamily(() => observable<boolean>(false)),
focusFamily: weakFamily(() => observable<boolean>(false)),

dimensions,
vw,
vh,
colorScheme,

ContainerContext: createContext<ContainerContextValue>({}),
containerLayoutFamily,
containerWidthFamily: weakFamily((key: WeakKey) => {
return observable((read) => {
return read(containerLayoutFamily(key))?.width || 0;
});
}),
containerHeightFamily: weakFamily((key: WeakKey) => {
return observable((read) => {
return read(containerLayoutFamily(key))?.height || 0;
});
}),
};
}

declare global {
var __react_native_css_reactivity:
| ReturnType<typeof createReactivityState>
| undefined;
}

globalThis.__react_native_css_reactivity ??= createReactivityState();

export const {
observableBatch,
hoverFamily,
activeFamily,
focusFamily,
dimensions,
vw,
vh,
colorScheme,
ContainerContext,
containerLayoutFamily,
containerWidthFamily,
containerHeightFamily,
} = globalThis.__react_native_css_reactivity;