Last active
November 1, 2025 23:58
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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