Skip to content

Instantly share code, notes, and snippets.

@markmals
Last active November 1, 2025 23:58
Show Gist options
  • Select an option

  • Save markmals/2e3f0dc4926fb8dc524628d9c4720352 to your computer and use it in GitHub Desktop.

Select an option

Save markmals/2e3f0dc4926fb8dc524628d9c4720352 to your computer and use it in GitHub Desktop.
Recreate the Remix 3 API on top of Preact using Preact's option hooks
import "./preact-remix.ts";
import type { Remix } from "@remix-run/dom";
import { createEventType, dom, events } from "@remix-run/events";
import { render } from "preact";
import type { PropsWithChildren } from "preact/compat";
export function App() {
return (
<StoreProvider>
<Counter />
</StoreProvider>
);
}
const [change, createChange] = createEventType<{ count: number }>("rmx-count");
class CountStore extends EventTarget {
static change = change;
count = 0;
#dispatchChange() {
this.dispatchEvent(createChange({ detail: { count: this.count } }));
}
increment() {
this.count += 1;
this.#dispatchChange();
}
decrement() {
this.count -= 1;
this.#dispatchChange();
}
}
function StoreProvider(this: Remix.Handle<CountStore>) {
const store = new CountStore();
this.context.set(store);
return ({ children }: PropsWithChildren) => (
<>
<button on={dom.click(() => store.increment())} type="button">
Increment
</button>
{children}
</>
);
}
function Counter(this: Remix.Handle) {
const store = this.context.get(StoreProvider);
events(store, [CountStore.change(() => this.update())]);
return () => (
<>
<span>
Double {store.count} is {store.count * 2}
</span>
<button
type="button"
on={dom.click(() => {
store.decrement();
})}
>
Decrement
</button>
</>
);
}
render(<App />, document.body);
import type { Remix } from "@remix-run/dom";
import type { EventDescriptor } from "@remix-run/events";
import type { EnhancedStyleProperties } from "@remix-run/style";
declare module "preact" {
namespace JSX {
interface HTMLAttributes<RefType extends EventTarget = EventTarget> {
on?: EventDescriptor<RefType> | EventDescriptor<RefType>[];
css?: EnhancedStyleProperties;
}
interface IntrinsicAttributes {
key?: any;
children?: any;
}
}
// Extract props from a Remix component (union of setup and render props)
type RemixComponentProps<T> = T extends (
this: Remix.Handle<any>,
props: infer SetupProps,
) => infer R
? R extends (props: infer RenderProps) => any
? SetupProps & Partial<RenderProps>
: SetupProps
: never;
// Override FunctionComponent to handle Remix components
type FunctionComponent<P = {}> = Remix.Component<any, P, any> extends infer C
? C extends (this: any, props: infer Props) => any
? (props: RemixComponentProps<C>) => VNode<any> | null
: (props: P) => VNode<any> | null
: (props: P) => VNode<any> | null;
}
import type { EventDescriptor } from "@remix-run/events";
import { events } from "@remix-run/events";
import { processStyle, createStyleManager, type EnhancedStyleProperties } from "@remix-run/style";
import type { ComponentChildren, VNode } from "preact";
import { options } from "preact";
// Style management for css prop
const styleManager = typeof window !== "undefined" ? createStyleManager() : null;
const styleCache = new Map<string, { className: string; css: string }>();
// Extend VNode to include internal Preact properties
interface PreactVNode extends VNode {
_dom?: Element | Text | null;
_component?: PreactComponent;
__e?: Element | Text | null;
}
// Internal Preact component instance
interface PreactComponent {
setState?: (state: unknown) => void;
__remixData?: ComponentData;
}
// Extended Element to include our custom properties
interface ExtendedElement extends Element {
__cssClassName?: string;
__eventCleanup?: () => void;
}
// Component data stored per instance
interface ComponentData {
handle: {
update: () => void;
signal: AbortSignal;
id: string;
context: ContextAPI;
queueTask: () => void;
raise: () => void;
frame: unknown;
};
renderFn: ((props: Record<string, unknown>) => unknown) | null;
abortController: AbortController;
vnode: PreactVNode;
componentFn?: Function;
contextValue?: unknown;
}
interface ContextAPI {
set(value: unknown): void;
get(component: Function | symbol): unknown;
}
// Walk up the vnode tree to find a component's context value
function getContextValue(startVNode: PreactVNode, targetComponent: Function | symbol): unknown {
let current: PreactVNode | null = startVNode;
// Walk up the parent chain
while (current) {
// Check if this vnode has a component instance
const component = (current as any).__c as PreactComponent | undefined;
if (component) {
const data = component.__remixData;
// Check if this component provides the context we're looking for
if (data && data.componentFn === targetComponent && data.contextValue !== undefined) {
return data.contextValue;
}
}
// Move to parent vnode
current = (current as any).__ as PreactVNode | null;
}
return undefined;
}
// Save original hooks to chain them
const oldVNodeHook = options.vnode;
const oldDiffedHook = options.diffed;
const oldUnmountHook = options.unmount;
options.vnode = (vnode: VNode) => {
if (oldVNodeHook) oldVNodeHook(vnode);
// Filter out custom props from intrinsic elements to prevent them appearing in DOM
if (typeof vnode.type === "string" && vnode.props) {
const { css, on, ...restProps } = vnode.props as any;
if (css !== undefined || on !== undefined) {
// Store original props for our hooks to access
(vnode as any).__originalProps = vnode.props;
// Replace props with filtered version
vnode.props = restProps;
}
}
// Only process function components (not class components or intrinsic elements)
if (typeof vnode.type === "function" && !vnode.type.prototype?.render) {
const originalFn = vnode.type as (
this: ComponentData["handle"],
props: Record<string, unknown>,
) => unknown;
// Store reference to the original component function
const componentFn = originalFn;
// Wrap the component to provide Remix.Handle as `this`
vnode.type = function (
this: PreactComponent,
props: Record<string, unknown>,
): ComponentChildren {
// Check if we have data for this component instance
let data = this.__remixData;
if (!data) {
// First render - initialize the component
const abortController = new AbortController();
// Get the vnode for this component instance
const currentVNode = (this as any).__v as PreactVNode;
// Create the context API
const contextAPI: ContextAPI = {
set: (value: unknown) => {
if (data) {
data.contextValue = value;
}
},
get: (component: Function | symbol) => {
return getContextValue(currentVNode, component);
},
};
// Create the Remix.Handle that will be passed as `this` to the component
const handle = {
update: () => {
// Trigger Preact re-render by calling setState on the internal component
if (this?.setState) {
this.setState({});
}
},
signal: abortController.signal,
// Stub other required properties for minimal compatibility
id: "",
context: contextAPI,
queueTask: () => {},
raise: () => {},
frame: {},
};
data = {
handle,
renderFn: null,
abortController,
vnode: currentVNode,
componentFn,
contextValue: undefined,
};
this.__remixData = data;
// Call the original component with our handle as `this`
const result = originalFn.call(handle, props);
// Check if the component returned a render function
const renderFn =
typeof result === "function" ? (result as ComponentData["renderFn"]) : null;
data.renderFn = renderFn;
// Return the appropriate result
if (renderFn) {
const renderResult = renderFn(props);
return renderResult as ComponentChildren;
}
return result as ComponentChildren;
}
// Subsequent renders - use the stored render function
if (data.renderFn) {
return data.renderFn(props) as ComponentChildren;
}
// If no render function, call the component again
return originalFn.call(data.handle, props) as ComponentChildren;
};
}
};
options.diffed = (vnode: VNode) => {
if (oldDiffedHook) oldDiffedHook(vnode);
const pvnode = vnode as PreactVNode;
// Use original props if available (for intrinsic elements with filtered props)
const props = ((vnode as any).__originalProps || vnode.props) as Record<string, unknown>;
// Handle the `on` prop for event binding
if (props.on) {
// Only attach to intrinsic elements (string types like 'button', 'div', etc.)
// Preact uses __e to store the DOM element
const domElement = pvnode.__e || pvnode._dom;
if (typeof vnode.type === "string" && domElement instanceof EventTarget) {
const element = domElement as ExtendedElement;
// Clean up previous event listeners
if (element.__eventCleanup) {
element.__eventCleanup();
}
// Attach new event listeners
const descriptors: EventDescriptor[] = Array.isArray(props.on)
? (props.on as EventDescriptor[])
: [props.on as EventDescriptor];
const cleanup = events(element, descriptors);
element.__eventCleanup = cleanup;
}
}
// Handle the `css` prop for dynamic styling
if (props.css && typeof props.css === "object" && typeof vnode.type === "string") {
const domElement = pvnode.__e || pvnode._dom;
if (domElement instanceof Element) {
const element = domElement as ExtendedElement;
const prevClassName = element.__cssClassName || "";
// Process the css object into a className and css string
const { className, css } = processStyle(
props.css as EnhancedStyleProperties,
styleCache,
);
// Only update if the className has changed
if (prevClassName !== className) {
// Remove previous className and styles
if (prevClassName) {
element.classList.remove(prevClassName);
styleManager?.remove(prevClassName);
}
// Add new className and styles
if (css && className) {
element.classList.add(className);
styleManager?.insert(className, css);
element.__cssClassName = className;
} else {
delete element.__cssClassName;
}
}
}
} else if (typeof vnode.type === "string") {
// If there's no css prop but there was one previously, clean it up
const domElement = pvnode.__e || pvnode._dom;
if (domElement instanceof Element) {
const element = domElement as ExtendedElement;
const prevClassName = element.__cssClassName;
if (prevClassName) {
element.classList.remove(prevClassName);
styleManager?.remove(prevClassName);
delete element.__cssClassName;
}
}
}
};
options.unmount = (vnode: VNode) => {
if (oldUnmountHook) oldUnmountHook(vnode);
const pvnode = vnode as PreactVNode;
// Clean up event listeners and CSS
const domElement = pvnode.__e || pvnode._dom;
if (domElement instanceof Element) {
const element = domElement as ExtendedElement;
if (element.__eventCleanup) {
element.__eventCleanup();
delete element.__eventCleanup;
}
// Clean up CSS classes and styles
if (element.__cssClassName) {
element.classList.remove(element.__cssClassName);
styleManager?.remove(element.__cssClassName);
delete element.__cssClassName;
}
}
// Clean up component data and abort the signal
if (pvnode._component) {
const data = pvnode._component.__remixData;
if (data) {
data.abortController.abort();
delete pvnode._component.__remixData;
}
}
};
export {};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment