feat(renderer): implement very basic reconciler

This commit is contained in:
ByteAtATime 2025-10-30 14:02:43 -07:00
parent 74fbb4f364
commit 3a319cf333
6 changed files with 178 additions and 60 deletions

View file

@ -2,7 +2,7 @@ fn main() {
println!("cargo::rerun-if-changed=renderer/src");
let status = std::process::Command::new("bun")
.args(["run", "build"])
.args(["run", "dev"])
.current_dir("renderer")
.status()
.expect("Failed to build renderer code");

View file

@ -5,10 +5,12 @@
"name": "renderer",
"dependencies": {
"react": "^19.2.0",
"react-reconciler": "^0.33.0",
},
"devDependencies": {
"@raycast/api": "^1.103.5",
"@types/bun": "latest",
"@types/react-reconciler": "^0.32.2",
},
"peerDependencies": {
"typescript": "^5",
@ -116,6 +118,8 @@
"@types/react": ["@types/react@19.0.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g=="],
"@types/react-reconciler": ["@types/react-reconciler@0.32.2", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-gjcm6O0aUknhYaogEl8t5pecPfiOTD8VQkbjOhgbZas/E6qGY+veW9iuJU/7p4Y1E0EuQ0mArga7VEOUWSlVRA=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@ -194,8 +198,12 @@
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],

View file

@ -5,16 +5,18 @@
"private": true,
"scripts": {
"watch": "bun build src/index.ts --target=node --minify --watch --outdir=dist",
"build": "bun build src/index.ts --target=node --minify --outdir=dist"
"dev": "bun build src/index.ts --target=node --outdir=dist"
},
"devDependencies": {
"@raycast/api": "^1.103.5",
"@types/bun": "latest"
"@types/bun": "latest",
"@types/react-reconciler": "^0.32.2"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"react": "^19.2.0"
"react": "^19.2.0",
"react-reconciler": "^0.33.0"
}
}

View file

@ -1,5 +1,7 @@
import React from "react";
import ReactJsxRuntime from "react/jsx-runtime";
import type * as RaycastApiType from "@raycast/api";
import { updateContainer } from "./reconciler";
const LaunchType = {
UserInitiated: "userInitiated",
@ -43,4 +45,4 @@ const raycastApi = {
},
};
export { React, raycastApi };
export { React, ReactJsxRuntime, raycastApi, updateContainer };

View file

@ -0,0 +1,91 @@
import Reconciler from "react-reconciler";
type HostComponent = {
type: string;
children: HostComponent[];
};
type Type = string;
interface TextInstance extends HostComponent {
type: "TEXT";
text: string;
}
type HostContext = object;
type Instance = HostComponent;
type ChildSet = Array<string | Instance>;
type Timeout = ReturnType<typeof setTimeout>;
type RootContainer = {
id: "root";
children: ChildSet;
};
const notImpl = () => {
throw new Error("Function not implemented.");
};
const HostConfig: Reconciler.HostConfig<
Type,
unknown, // Props
RootContainer,
Instance,
TextInstance,
void, // SuspenseInstance
void, // HydratableInstance
void, // FormInstance
Instance, // PublicInstance
HostContext,
ChildSet,
Timeout, // TimeoutHandle
-1, // NoTimeout
void // TransitionStatus
> = {
supportsPersistence: true,
supportsMutation: false,
resolveUpdatePriority: () => 1,
getCurrentUpdatePriority: () => 1,
setCurrentUpdatePriority: () => {},
resolveEventTimeStamp: () => -1.1,
resolveEventType: () => null,
trackSchedulerEvent: () => {},
getRootHostContext: () => ({}),
createInstance(type, props, rootContainer, hostContext, internalHandle) {
console.log("createInstance", type, props);
return {
type: type,
children: [],
};
},
prepareForCommit: () => null,
resetAfterCommit: () => null,
};
const reconciler = Reconciler(HostConfig);
const root: RootContainer = { id: "root", children: [] };
const container = reconciler.createContainer(
root,
0, // LegacyRoot
null, // hydrationCallbacks
false, // isStrictMode
null, // concurrentUpdatesByDefaultOverride
"", // identifierPrefix
console.log, // onUncaughtError
console.log, // onCaughtError
console.log, // onRecoverableError
() => {}, // onDefaultTransitionIndicator
null
);
export const updateContainer = (
element: React.ReactElement,
callback?: () => void
) => {
reconciler.updateContainer(element, container, null, callback);
};
export const batchedUpdates = (callback: () => void) => {
reconciler.batchedUpdates(callback, null);
};

View file

@ -1,9 +1,9 @@
use iced::alignment::Vertical;
use iced::futures;
use iced::futures::channel::mpsc;
use iced::futures::{SinkExt, StreamExt};
use iced::widget::{button, column, container, row, text};
use iced::{Color, Element, Font, Length, Padding, Theme, border};
use iced::{Subscription, futures};
use iced::widget::{column, container, text};
use iced::{Color, Element, Font, Length, Subscription, Theme};
use rustyscript::deno_core::PollEventLoopOptions;
use rustyscript::{Module, Runtime, RuntimeOptions, serde_json::Value};
use std::sync::Mutex;
@ -70,71 +70,86 @@ fn subscription(_state: &State) -> Subscription<Message> {
}
}
fn main() -> Result<(), rustyscript::Error> {
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (sender, receiver) = mpsc::unbounded();
*SENDER.lock().unwrap() = Some(sender);
*RECEIVER.lock().unwrap() = Some(receiver);
let mut runtime = Runtime::new(RuntimeOptions {
..Default::default()
})?;
std::thread::spawn(|| {
let mut runtime = Runtime::new(RuntimeOptions::default()).unwrap();
let renderer_module = Module::new("renderer.js", include_str!("../renderer/dist/index.js"));
runtime.load_module(&renderer_module)?;
let renderer_module = Module::new("renderer.js", include_str!("../renderer/dist/index.js"));
runtime.load_module(&renderer_module).unwrap();
let module = Module::new(
"setup.js",
"
import { createRequire } from 'module';
const nodeRequire = createRequire(import.meta.url);
import { raycastApi } from './renderer.js';
globalThis.require = (moduleName) => {
if (moduleName === '@raycast/api') {
return raycastApi;
}
return nodeRequire(moduleName);
};
globalThis.module = { exports: {} };
",
);
let module2 = Module::new("plugin.js", include_str!("../test/plugin.js"));
let command_runner = Module::new(
"runner.js",
r#"
await module.exports.default();
"#,
);
runtime.register_async_function("showToast", |args| {
Box::pin(async move {
if let Ok(value) = serde_json::from_value::<ToastOptions>(args[0].clone()) {
if let Some(mut sender) = SENDER.lock().unwrap().clone() {
sender
.send(Message::UpdateToast(value.title.clone()))
.await
.unwrap();
let module = Module::new(
"setup.js",
"
import { createRequire } from 'module';
const nodeRequire = createRequire(import.meta.url);
import { raycastApi, React, ReactJsxRuntime } from './renderer.js';
globalThis.require = (moduleName) => {
if (moduleName === '@raycast/api') {
return raycastApi;
}
}
Ok(Value::Null)
})
})?;
if (moduleName === 'react') return React;
if (moduleName === 'react/jsx-runtime') return ReactJsxRuntime;
return nodeRequire(moduleName);
};
globalThis.module = { exports: {} };
",
);
runtime.load_module(&module).unwrap();
runtime.load_module(&module)?;
runtime.load_module(&module2)?;
let module2 = Module::new("plugin.js", include_str!("../test/plugin.js"));
runtime.load_module(&module2).unwrap();
let tokio_runtime = runtime.tokio_runtime();
let command_runner = Module::new(
"runner.js",
r#"
import { React, updateContainer } from './renderer.js';
const PluginRoot = module.exports.default;
const AppElement = React.createElement(PluginRoot);
updateContainer(AppElement, () => {
console.log("initial render callback fired!");
});
"#,
);
tokio_runtime.block_on(async { runtime.load_module_async(&command_runner).await })?;
runtime
.register_async_function("showToast", |args| {
Box::pin(async move {
if let Ok(value) = serde_json::from_value::<ToastOptions>(args[0].clone()) {
if let Some(mut sender) = SENDER.lock().unwrap().clone() {
sender
.send(Message::UpdateToast(value.title.clone()))
.await
.unwrap();
}
}
Ok(Value::Null)
})
})
.unwrap();
runtime.load_module(&command_runner).unwrap();
runtime
.block_on_event_loop(PollEventLoopOptions::default(), None)
.unwrap();
});
iced::application("flare", update, view)
.subscription(subscription)
.font(include_bytes!("./assets/Inter.ttf").as_slice())
.default_font(iced::Font::DEFAULT)
.run()
.map_err(|e| rustyscript::Error::Runtime(e.to_string()))
.map_err(|e| e.to_string())?;
Ok(())
}