Compare commits

...

5 Commits

  1. 8
      .gitignore
  2. 2919
      package-lock.json
  3. 12
      package.json
  4. 79
      playwright.config.ts
  5. 7
      src/app/components/Highlight.astro
  6. 2
      src/app/components/SvelteRoot.svelte
  7. 6
      src/app/pages/index.astro
  8. 17
      src/frontends/react/HelloWorld.tsx
  9. 0
      src/frontends/react/SvelteWrapper.tsx
  10. 0
      src/frontends/react/VueWrapper.tsx
  11. 14
      src/frontends/svelte/HelloWorld.svelte
  12. 22
      src/frontends/svelte/mount.ts
  13. 10
      src/frontends/tests/reactiveCrossFramework.spec.ts
  14. 30
      src/frontends/vue/HelloWorld.vue
  15. 8
      src/frontends/vue/mount.ts
  16. 7
      src/ng-mock/js-land/connector/applyDiff.ts
  17. 84
      src/ng-mock/js-land/connector/createSignalObjectForShape.ts
  18. 31
      src/ng-mock/js-land/connector/ngSignals.ts
  19. 22
      src/ng-mock/js-land/frontendAdapters/react/useShape.ts
  20. 16
      src/ng-mock/js-land/frontendAdapters/svelte/useShape.ts
  21. 14
      src/ng-mock/js-land/frontendAdapters/vue/useShape.ts
  22. 0
      src/ng-mock/js-land/signalLibs/alienSignals.ts
  23. 0
      src/ng-mock/tests/subscriptionToStore.test.ts
  24. 27
      src/ng-mock/tests/updatesWithWasm.test.ts
  25. 13
      src/ng-mock/wasm-land/requestShape.ts
  26. 4
      src/ng-mock/wasm-land/updateShape.ts

8
.gitignore vendored

@ -33,4 +33,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
.astro
.astro
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

2919
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -9,21 +9,31 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"test": "vitest"
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/react": "4.3.0",
"@astrojs/svelte": "7.1.0",
"@astrojs/vue": "^5.1.0",
"@gn8/alien-signals-react": "^0.1.1",
"@gn8/alien-signals-solid": "^0.1.1",
"@gn8/alien-signals-svelte": "^0.1.1",
"@gn8/alien-signals-vue": "^0.1.1",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"alien-deepsignals": "^0.1.0",
"alien-signals": "^2.0.7",
"astro": "5.13.2",
"install": "^0.13.0",
"npm": "^11.5.2",
"react": "19.1.1",
"react-dom": "19.1.1",
"svelte": "5.38.2",
"vue": "3.5.19"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "24.3.0",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",

@ -0,0 +1,79 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./src/frontends/tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:4321",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev",
url: "http://localhost:4321",
reuseExistingServer: !process.env.CI,
},
});

@ -1,11 +1,4 @@
---
interface Props {
react?: boolean;
svelte?: boolean;
vue?: boolean;
astro?: boolean;
}
const { react, svelte, vue, astro } = Astro.props;
const frameworkName = Object.keys(Astro.props)[0];

@ -1,7 +1,5 @@
<script lang="ts">
import HelloWorld from "src/frontends/svelte/HelloWorld.svelte";
//
</script>
<HelloWorld />

@ -11,14 +11,14 @@ const title = "Multi-framework app";
<Layout title={title}>
<Highlight vue>
<VueRoot client:load />
<VueRoot client:only />
</Highlight>
<Highlight react>
<ReactRoot client:visible />
<ReactRoot client:only="react" />
</Highlight>
<Highlight svelte>
<SvelteRoot client:load />
<SvelteRoot client:only />
</Highlight>
</Layout>

@ -1,9 +1,26 @@
import React from "react";
import useShape from "../../ng-mock/js-land/frontendAdapters/react/useShape";
export function HelloWorldReact() {
const state = useShape("Shape1", "");
window.reactState = state;
console.log("react render", state);
if (!state) return <>Loading state</>;
return (
<div>
<p>Hello World from React!</p>
{state.name} lives at {state.address.street}
<p></p>
<button
onClick={() => {
state.name = `${state.name} ${state.name}`;
}}
>
Double name
</button>
</div>
);
}

@ -1,7 +1,19 @@
<script>
// Simple Svelte component
import useShape from "../../ng-mock/js-land/frontendAdapters/svelte/useShape";
const nestedObject = useShape("Shape1", null);
</script>
<div>
<p>Hello World from Svelte!</p>
<pre>{JSON.stringify($nestedObject, null, 4)}</pre>
<button
on:click={() => {
window.svelteState = $nestedObject;
$nestedObject.name = $nestedObject.name.toUpperCase();
}}
>
upper-case name
</button>
</div>

@ -1,22 +0,0 @@
// Simple Svelte-like component using vanilla JS
export function mountSvelteComponent(element: HTMLElement) {
// Create the DOM structure that would be created by the Svelte component
element.innerHTML = `
<div style="
padding: 20px;
border: 2px solid #000000ff;
border-radius: 8px;
margin: 10px;
background-color: #fff5f5;
">
<h2>Svelte Component</h2>
<p>Hello World from Svelte!</p>
</div>
`;
return {
$destroy: () => {
element.innerHTML = "";
},
};
}

@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
test("components load", async ({ page }) => {
await page.goto("/");
await page.waitForSelector(".vue astro-island");
await expect(page.locator(".vue .title")).toHaveText("vue");
await expect(page.locator(".react .title")).toHaveText("react");
await expect(page.locator(".svelte .title")).toHaveText("svelte");
});

@ -1,9 +1,29 @@
<script setup lang="ts">
import type { WritableComputedRef } from 'vue';
import useShape from '../../ng-mock/js-land/frontendAdapters/vue/useShape';
const shapeObj = useShape("Shape1", "null");
window.vueState= shapeObj;
const setName = () => {
shapeObj.value.name = "Bobby"
}
</script>
<template>
<div>
<p>Hello World from Vue!</p>
</div>
</template>
<div v-if="shapeObj != null">
<p>Type is <em>{{shapeObj.type}}</em> with street <em>{{shapeObj.address.street}}</em></p>
<script setup lang="ts">
// Simple Vue component with composition API
</script>
<button @click="setName">
Click to switch name
</button>
</div>
<div v-else>
Loading state
</div>
</div>
</template>

@ -1,8 +0,0 @@
import { createApp } from "vue";
import HelloworldVue from "./HelloWorld.vue";
export function mountVueComponent(element: HTMLElement) {
const app = createApp(HelloworldVue);
app.mount(element);
return app;
}

@ -1,6 +1,9 @@
import type { Diff } from "../types";
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */
export function applyDiff(currentState: object, diff: Diff): object {
return JSON.parse(JSON.stringify(diff));
export function applyDiff(currentState: any, diff: Diff) {
const clone = JSON.parse(JSON.stringify(diff));
Object.keys(clone).forEach((k) => {
currentState[k] = clone[k];
});
}

@ -0,0 +1,84 @@
import updateShape from "src/ng-mock/wasm-land/updateShape";
import type { Connection, Diff, Scope, Shape } from "../types";
import requestShape from "src/ng-mock/wasm-land/requestShape";
import { applyDiff } from "./applyDiff";
import { batch, deepSignal, watch } from "alien-deepsignals";
import { signal as createSignal, computed } from "alien-signals";
type ReactiveShapeObject = object;
type ShapeObjectSignal = ReturnType<
typeof createSignal<{
content: ReactiveShapeObject | null;
}>
>;
const openConnections: Partial<Record<Shape, ShapeObjectSignal>> = {};
/**
* Create a signal for a shape object.
* The function returns a signal of a shape object in the form:
* `{content: <shapeObject>}`
**/
export function createSignalObjectForShape(
shape: Shape,
scope?: Scope,
poolSignal = true
) {
if (poolSignal && openConnections[shape]) return openConnections[shape];
// DeepSignal has a different API to alien-signals.
// Therefore, we need to create a "root signal" wrapper that is
// triggered on deepSignal changes.
const rootSignal = createSignal<{
content: ReactiveShapeObject | null;
}>({ content: null });
// State
let stopWatcher: any = null;
let suspendDeepWatcher = false;
const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => {
const rootSignalValue = rootSignal();
console.log("Update received", connectionId, diff);
// Set new value from applying the diffs to the old value.
suspendDeepWatcher = true;
// We need to replace the root signal for now, so this is redundant.
batch(() => {
if (!rootSignalValue) return; // This shouldn't happen but we make the compiler happy.
const { content: proxiedShapeObj } = rootSignalValue;
applyDiff(proxiedShapeObj, diff);
// We put the proxied object into a new object for the root signal to trigger.
rootSignal({ content: proxiedShapeObj });
});
suspendDeepWatcher = false;
};
// Do the actual db request.
requestShape(shape, scope, onUpdateFromDb).then(
({ connectionId, shapeObject }) => {
// Create a deepSignal to put into the vanilla alien-signal.
const proxiedShapeObj = deepSignal(shapeObject);
// Notify DB on changes.
stopWatcher = watch(
proxiedShapeObj,
(newVal, oldVal, onCleanup) => {
// Don't update when applying changes from db diffs from the db.
if (!suspendDeepWatcher) updateShape(connectionId, newVal);
},
{ deep: true }
);
// Update the root signal.
rootSignal({ content: proxiedShapeObj });
}
);
if (poolSignal) openConnections[shape] = rootSignal;
// TODO: Dispose deepSignal root signal disposal.
return rootSignal;
}

@ -1,31 +0,0 @@
import handleShapeUpdate from "src/ng-mock/wasm-land/handleShapeUpdate";
import type { Connection, Diff, Scope, Shape } from "../types";
import handleShapeRequest from "src/ng-mock/wasm-land/handleShapeRequest";
import { applyDiff } from "./applyDiff";
export async function createSignalObjectForShape(shape: Shape, scope?: Scope) {
const ret: {
state?: any;
connectionId?: Connection["id"];
update: (diff: Diff) => Promise<void>;
} = {
async update(diff) {
if (!ret.connectionId)
throw new Error("Connection not established yet for shape" + shape);
await handleShapeUpdate(ret.connectionId, diff);
},
};
const onDbUpdate = (diff: Diff) => {
ret.state = applyDiff(ret.state || {}, diff);
};
await handleShapeRequest(shape, onDbUpdate).then(
({ connectionId, shapeObject }) => {
ret.state = shapeObject;
ret.connectionId = connectionId;
}
);
return ret;
}

@ -0,0 +1,22 @@
import { useSignal } from "@gn8/alien-signals-react";
import { useMemo, useRef } from "react";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
// TODO: The universe-signal library causes two renders on change instead of one.
// Find a fix
const useShape = (shape: Shape, scope: Scope) => {
const signalOfShape = useMemo(() => {
console.log("react memo called...");
return createSignalObjectForShape(shape, scope);
}, [shape, scope]);
const [{ content: shapeObject }, setShapeObject] = useSignal(signalOfShape);
// We don't need the setter.
// The object is recursively proxied and changes are recorded there.
return shapeObject;
};
export default useShape;

@ -0,0 +1,16 @@
import { derived } from "svelte/store";
import { useSignal } from "@gn8/alien-signals-svelte";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
const useShape = (shape: Shape, scope: Scope) => {
const signalOfShape = createSignalObjectForShape(shape, scope);
const writeableStoreForShape = useSignal(signalOfShape);
// Get the content "deepSignal"
const content = derived(writeableStoreForShape, (value) => value.content);
return content;
};
export default useShape;

@ -0,0 +1,14 @@
import { useSignal } from "@gn8/alien-signals-vue";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
import { computed } from "vue";
const useShape = (shape: Shape, scope: Scope) => {
const signalOfShape = createSignalObjectForShape(shape, scope);
const refOfShape = useSignal(signalOfShape);
// TODO: Maybe `refOfShape.value.content` works too?
return computed(() => refOfShape.value.content);
};
export default useShape;

@ -1,23 +1,30 @@
import { expect, test } from "vitest";
import { createSignalObjectForShape } from "../js-land/connector/ngSignals";
import { createSignalObjectForShape } from "../js-land/connector/createSignalObjectForShape";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// TODO: Redo
test("shape object notification comes back to others", async () => {
const object1 = await createSignalObjectForShape("Shape1");
const object2 = await createSignalObjectForShape("Shape1");
const object1 = createSignalObjectForShape("Shape1");
const object2 = createSignalObjectForShape("Shape1");
const object3 = createSignalObjectForShape("Shape2");
const object4 = createSignalObjectForShape("Shape2");
const object3 = await createSignalObjectForShape("Shape2");
const object4 = await createSignalObjectForShape("Shape2");
wait(50);
// Update object 1 and expect object 2 to update as well.
await object1.update({ name: "Updated name from object1" });
object1()!.name = "Updated name from object1";
expect(object2.state?.name).toBe("Updated name from object1");
wait(30);
expect(object2()!.name).toBe("Updated name from object1");
// Expect object of different shape not to have changed.
expect(object3.state?.name).toBe("Niko's cat");
expect(object3().name).toBe("Niko's cat");
// Update object 4 and expect object 3 with same shape to have updated.
await object4.update({ name: "Updated name from object4" });
object4()!.name = "Updated name from object4";
expect(object3.state?.name).toBe("Updated name from object4");
wait(30);
expect(object3()!.name).toBe("Updated name from object4");
});

@ -1,6 +1,5 @@
import { randomUUID } from "crypto";
import * as shapeManager from "./shapeManager";
import type { WasmConnection, Diff } from "./types";
import type { WasmConnection, Diff, Scope } from "./types";
import type { Shape } from "../js-land/types";
const mockShapeObject1 = {
@ -20,19 +19,23 @@ const mockShapeObject2 = {
numberOfHomes: 3,
address: {
street: "Niko's street",
compartment: 2,
houseNumber: "15",
floor: 0,
},
};
export default async function handleShapeRequest(
let connectionIdCounter = 1;
export default async function requestShape(
shape: Shape,
scope: Scope | undefined,
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void
): Promise<{
connectionId: string;
shapeObject: object;
}> {
const connection: WasmConnection = {
id: randomUUID(),
id: String(connectionIdCounter++),
shape,
// Create a deep copy to prevent accidental by-reference changes.
state: JSON.parse(

@ -1,13 +1,15 @@
import * as shapeManager from "./shapeManager";
import type { WasmConnection, Diff } from "./types";
export default async function handleShapeUpdate(
export default async function updateShape(
connectionId: WasmConnection["id"],
diff: Diff
) {
const connection = shapeManager.connections.get(connectionId);
if (!connection) throw new Error("No Connection found.");
console.log("BACKEND: Received update request from ", connectionId);
const newState = shapeManager.applyDiff(connection.state, diff);
connection.state = newState;
Loading…
Cancel
Save