From 7d3b956f7f13a23bff7a4840c231679af9f210ab Mon Sep 17 00:00:00 2001 From: sarthakdev143-lite Date: Sat, 21 Mar 2026 09:45:09 +0530 Subject: [PATCH 1/3] feat(core): add context API --- CHANGELOG.md | 7 +++ apps/demo/src/feed.ts | 26 ++++++++++- apps/demo/src/main.ts | 20 +++++++- apps/demo/src/styles.css | 73 ++++++++++++++++++++++++++++++ apps/demo/src/theme.ts | 15 ++++++ packages/core/src/context.ts | 39 ++++++++++++++++ packages/core/src/index.ts | 11 ++++- packages/core/tests/signal.test.ts | 65 +++++++++++++++++++++++++- packages/runtime/src/dom.ts | 28 +++++++++--- packages/runtime/src/index.ts | 2 +- packages/runtime/src/jsx.ts | 30 ++++++++++++ 11 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 apps/demo/src/theme.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f25cc1..721e683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Context API helpers via `createContext`, `provideContext`, and `useContext` +- Runtime `createProvider()` support for provider-backed component trees + +### Changed +- Demo app now uses context for theme state instead of local prop wiring + ## [0.3.0] - 2026-03-21 ### Added diff --git a/apps/demo/src/feed.ts b/apps/demo/src/feed.ts index a14d9a6..db36f1a 100644 --- a/apps/demo/src/feed.ts +++ b/apps/demo/src/feed.ts @@ -1,6 +1,7 @@ -import { createMutation, createQuery, createSignal, createStore, h } from "@sarthakdev143/shadejs"; +import { createMutation, createQuery, createSignal, createStore, h, useContext } from "@sarthakdev143/shadejs"; import { addPost, getPosts } from "./posts.server"; +import { ThemeContext } from "./theme"; const [count, setCount] = createSignal(0); const composer = createStore({ @@ -11,6 +12,24 @@ const { mutate: submitPost, pending: isSubmitting } = createMutation(addPost, { invalidates: ["posts"] }); +function ThemeControls() { + const { theme, toggleTheme } = useContext(ThemeContext); + + return h( + "div", + { className: "theme-controls" }, + h("span", { className: "theme-chip" }, () => `Theme: ${theme()}`), + h( + "button", + { + className: "theme-button", + onClick: toggleTheme + }, + () => (theme() === "dark" ? "Switch to light" : "Switch to dark") + ) + ); +} + function renderPosts() { const state = posts(); @@ -48,9 +67,11 @@ async function handleAddPost(): Promise { } export function Feed() { + const { theme } = useContext(ThemeContext); + return h( "main", - { className: "shell" }, + { className: "shell", "data-theme": theme }, h( "section", { className: "hero panel" }, @@ -61,6 +82,7 @@ export function Feed() { { className: "lede" }, "The counter uses signals, the composer uses createStore, and the feed uses compiler-generated RPC stubs." ), + h(ThemeControls, null), h( "div", { className: "counter-card" }, diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index cd467be..7b1c910 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -1,7 +1,8 @@ -import { mount } from "@sarthakdev143/shadejs"; +import { createSignal, h, mount } from "@sarthakdev143/shadejs"; import { Feed } from "./feed"; import "./styles.css"; +import { ThemeProvider, type ThemeMode } from "./theme"; const app = document.querySelector("#app"); @@ -9,4 +10,19 @@ if (!(app instanceof Element)) { throw new Error("ShadeJS demo root element was not found."); } -mount(Feed, app); +function App() { + const [theme, setTheme] = createSignal("dark"); + + return h( + ThemeProvider, + { + value: { + theme, + toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark")) + } + }, + h(Feed, null) + ); +} + +mount(App, app); diff --git a/apps/demo/src/styles.css b/apps/demo/src/styles.css index 5f5138f..d5cb93a 100644 --- a/apps/demo/src/styles.css +++ b/apps/demo/src/styles.css @@ -35,6 +35,7 @@ input { max-width: 72rem; margin: 0 auto; padding: 2rem 1.25rem 3rem; + color: #f4efe7; } .panel { @@ -95,6 +96,43 @@ input { padding: 1.5rem; } +.theme-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.theme-chip { + display: inline-flex; + align-items: center; + min-height: 2.6rem; + padding: 0.45rem 0.9rem; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.78rem; + color: rgba(244, 239, 231, 0.82); + background: rgba(255, 255, 255, 0.05); +} + +.theme-button { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + padding: 0.75rem 1rem; + color: inherit; + background: rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: transform 140ms ease, background 140ms ease; +} + +.theme-button:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.1); +} + .counter-card { display: grid; gap: 0.65rem; @@ -202,6 +240,41 @@ input { color: #ff9f8f; } +.shell[data-theme="light"] { + color: #281d14; +} + +.shell[data-theme="light"] .panel { + border-color: rgba(103, 75, 41, 0.14); + background: linear-gradient(180deg, rgba(255, 249, 241, 0.97), rgba(240, 230, 216, 0.92)); + box-shadow: 0 1rem 3rem rgba(68, 45, 21, 0.12); +} + +.shell[data-theme="light"] .lede, +.shell[data-theme="light"] .panel-head p, +.shell[data-theme="light"] .counter-label, +.shell[data-theme="light"] .field-label, +.shell[data-theme="light"] .status { + color: rgba(40, 29, 20, 0.68); +} + +.shell[data-theme="light"] .theme-chip, +.shell[data-theme="light"] .counter-card, +.shell[data-theme="light"] .post, +.shell[data-theme="light"] .post-input { + border-color: rgba(103, 75, 41, 0.12); + background: rgba(109, 82, 51, 0.07); +} + +.shell[data-theme="light"] .theme-button { + border-color: rgba(103, 75, 41, 0.18); + background: rgba(109, 82, 51, 0.08); +} + +.shell[data-theme="light"] .post-id { + color: #b16a29; +} + @media (min-width: 860px) { .shell { grid-template-columns: 1.2fr 0.8fr; diff --git a/apps/demo/src/theme.ts b/apps/demo/src/theme.ts new file mode 100644 index 0000000..0fcd7af --- /dev/null +++ b/apps/demo/src/theme.ts @@ -0,0 +1,15 @@ +import { createContext, createProvider, type Accessor } from "@sarthakdev143/shadejs"; + +export type ThemeMode = "dark" | "light"; + +export interface ThemeContextValue { + theme: Accessor; + toggleTheme: () => void; +} + +export const ThemeContext = createContext({ + theme: () => "dark", + toggleTheme: () => {} +}); + +export const ThemeProvider = createProvider(ThemeContext); diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index cbe5f68..d04a811 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -3,9 +3,48 @@ import type { Computation } from "./signal"; export let currentObserver: Computation | null = null; type ErrorHandler = (error: unknown, computation: Computation) => void; type CleanupScope = Array<() => void>; +const contextStack = new Map(); const cleanupStack: CleanupScope[] = []; const errorHandlerStack: ErrorHandler[] = []; +export interface Context { + id: symbol; + defaultValue: T; +} + +export function createContext(defaultValue: T): Context { + return { + defaultValue, + id: Symbol("shadejs.context") + }; +} + +export function provideContext(context: Context, value: T, fn: () => TResult): TResult { + const stack = contextStack.get(context.id) ?? []; + stack.push(value); + contextStack.set(context.id, stack); + + try { + return fn(); + } finally { + stack.pop(); + + if (stack.length === 0) { + contextStack.delete(context.id); + } + } +} + +export function useContext(context: Context): T { + const stack = contextStack.get(context.id); + + if (stack === undefined || stack.length === 0) { + return context.defaultValue; + } + + return stack[stack.length - 1] as T; +} + export function runWithObserver(observer: Computation, fn: () => T): T { const previousObserver = currentObserver; currentObserver = observer; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07dd2c3..d065b0b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,15 @@ -export { currentObserver, onCleanup, pushErrorHandler, runWithObserver } from "./context"; +export { + createContext, + currentObserver, + onCleanup, + provideContext, + pushErrorHandler, + runWithObserver, + useContext +} from "./context"; export { flushMountCallbacks, onMount, withCleanupScope } from "./lifecycle"; export { batch, flushEffects, isFlushing, pendingEffects, scheduleEffect, setEffectErrorHandler } from "./scheduler"; export { createEffect, createMemo, createSignal } from "./signal"; +export type { Context } from "./context"; export type { EffectErrorHandler } from "./scheduler"; export type { Accessor, Computation, Setter, Updater } from "./signal"; diff --git a/packages/core/tests/signal.test.ts b/packages/core/tests/signal.test.ts index 7284751..e6462e2 100644 --- a/packages/core/tests/signal.test.ts +++ b/packages/core/tests/signal.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { batch, + createContext, createEffect, createMemo, createSignal, @@ -10,8 +11,10 @@ import { onCleanup, onMount, pendingEffects, + provideContext, pushErrorHandler, - setEffectErrorHandler + setEffectErrorHandler, + useContext } from "../src/index"; function waitForMicrotask(): Promise { @@ -28,7 +31,7 @@ afterEach(() => { }); }); -describe("@shadejs/core", () => { +describe("@sarthakdev143/core", () => { it("reads and writes signals", () => { const [count, setCount] = createSignal(0); @@ -255,6 +258,64 @@ describe("@shadejs/core", () => { expect(count()).toBe(5); }); + it("useContext returns the default value without a provider", () => { + const theme = createContext("dark"); + + expect(useContext(theme)).toBe("dark"); + }); + + it("useContext returns the provided value inside provideContext", () => { + const theme = createContext("dark"); + + const value = provideContext(theme, "light", () => useContext(theme)); + + expect(value).toBe("light"); + }); + + it("nested providers allow the inner value to shadow the outer value", () => { + const theme = createContext("dark"); + + const value = provideContext(theme, "light", () => + provideContext(theme, "contrast", () => useContext(theme)) + ); + + expect(value).toBe("contrast"); + }); + + it("restores the outer value after an inner provider exits", () => { + const theme = createContext("dark"); + let restoredValue = ""; + + provideContext(theme, "light", () => { + provideContext(theme, "contrast", () => { + expect(useContext(theme)).toBe("contrast"); + }); + + restoredValue = useContext(theme); + }); + + expect(restoredValue).toBe("light"); + }); + + it("context works with reactive signals as values", async () => { + const [theme, setTheme] = createSignal("dark"); + const ThemeContext = createContext(theme); + const seenValues: string[] = []; + + provideContext(ThemeContext, theme, () => { + const themedSignal = useContext(ThemeContext); + + createEffect(() => { + seenValues.push(themedSignal()); + }); + }); + + setTheme("light"); + await waitForMicrotask(); + + expect(seenValues).toEqual(["dark", "light"]); + }); + it("does not crash the scheduler when an effect throws", async () => { const [count, setCount] = createSignal(0); const [other, setOther] = createSignal(0); diff --git a/packages/runtime/src/dom.ts b/packages/runtime/src/dom.ts index 2f8d9a7..f323a49 100644 --- a/packages/runtime/src/dom.ts +++ b/packages/runtime/src/dom.ts @@ -1,6 +1,14 @@ import { createEffect } from "@sarthakdev143/core"; -import { Fragment, type JSXDescriptor, type Primitive, type Props, type ReactiveChild, type Renderable } from "./jsx"; +import { + Fragment, + renderWithProvider, + type JSXDescriptor, + type Primitive, + type Props, + type ReactiveChild, + type Renderable +} from "./jsx"; import { configureKeyedReconciler, reconcileKeyedList, type KeyedNode } from "./reconciler"; type DOMPropertyTarget = Element & Record; @@ -276,11 +284,19 @@ function createElementNodes(descriptor: JSXDescriptor): Node[] { } if (typeof descriptor.tag === "function") { - return createDOMNodes( - descriptor.tag({ - ...stripKeyProp(descriptor.props), - children: descriptor.children - }) + const component = descriptor.tag; + + return renderWithProvider( + component, + stripKeyProp(descriptor.props), + descriptor.children, + () => + createDOMNodes( + component({ + ...stripKeyProp(descriptor.props), + children: descriptor.children + }) + ) ); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2d9bd2d..b3bfc28 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,5 +1,5 @@ export { createDOMNode, disposeNode } from "./dom"; export { ErrorBoundary } from "./error-boundary"; -export { Fragment, h } from "./jsx"; +export { createProvider, Fragment, h } from "./jsx"; export type { Component, JSXDescriptor, Primitive, Props, ReactiveChild, Renderable, Tag } from "./jsx"; export { mount } from "./render"; diff --git a/packages/runtime/src/jsx.ts b/packages/runtime/src/jsx.ts index 690e7bc..790dab1 100644 --- a/packages/runtime/src/jsx.ts +++ b/packages/runtime/src/jsx.ts @@ -1,4 +1,7 @@ +import { provideContext, type Context } from "@sarthakdev143/core"; + export const Fragment = Symbol("ShadowFragment"); +const providerMarker = Symbol("shadejs.provider"); export type Key = number | string; export type Primitive = boolean | null | number | string | undefined; @@ -9,6 +12,9 @@ export type Component = { bivarianceHack(props: Props & { children?: Renderable[] }): Renderable; }["bivarianceHack"]; export type Tag = Component | typeof Fragment | string; +export type ProviderComponent = Component & { + [providerMarker]?: Context; +}; export interface JSXDescriptor { children: Renderable[]; @@ -39,6 +45,30 @@ export function h(tag: Tag, props: Props | null, ...children: Renderable[]): JSX }; } +export function createProvider(context: Context): ProviderComponent { + const Provider: ProviderComponent = (props: Props & { children?: Renderable[] }): Renderable => { + const children = props.children ?? []; + return children.length === 1 ? children[0] : children; + }; + + Provider[providerMarker] = context; + return Provider; +} + +export function renderWithProvider(tag: Tag, props: Props, children: Renderable[], fn: () => T): T { + if (typeof tag !== "function") { + return fn(); + } + + const context = (tag as ProviderComponent)[providerMarker]; + + if (context === undefined) { + return fn(); + } + + return provideContext(context, props.value, fn); +} + declare global { namespace JSX { type Element = JSXDescriptor; From 33f6c9604252b396dc8bcb4c25f4af6203d4b1a5 Mon Sep 17 00:00:00 2001 From: sarthakdev143-lite Date: Sat, 21 Mar 2026 10:00:37 +0530 Subject: [PATCH 2/3] feat(runtime): add JSX type safety --- CHANGELOG.md | 2 + apps/demo/src/feed.ts | 2 +- packages/runtime/package.json | 7 +- packages/runtime/src/dom.ts | 5 ++ packages/runtime/src/index.ts | 16 ++++ packages/runtime/src/jsx-types.ts | 105 +++++++++++++++++++++++ packages/runtime/src/jsx.ts | 27 +++--- packages/runtime/tests/jsx-types.test.ts | 40 +++++++++ tsconfig.base.json | 1 + 9 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 packages/runtime/src/jsx-types.ts create mode 100644 packages/runtime/tests/jsx-types.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 721e683..e85a922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Context API helpers via `createContext`, `provideContext`, and `useContext` - Runtime `createProvider()` support for provider-backed component trees +- Typed intrinsic element props for `h()` plus compile-time JSX prop coverage ### Changed - Demo app now uses context for theme state instead of local prop wiring +- Runtime package now exports `jsx-types` declarations for downstream tooling ## [0.3.0] - 2026-03-21 diff --git a/apps/demo/src/feed.ts b/apps/demo/src/feed.ts index db36f1a..135bc6b 100644 --- a/apps/demo/src/feed.ts +++ b/apps/demo/src/feed.ts @@ -102,7 +102,7 @@ export function Feed() { "section", { className: "panel composer" }, h("div", { className: "panel-head" }, h("h2", null, "Create a server post")), - h("label", { className: "field-label", for: "post-title" }, "Draft title"), + h("label", { className: "field-label", htmlFor: "post-title" }, "Draft title"), h("input", { className: "post-input", id: "post-title", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e1dd386..5ae5105 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -15,10 +15,15 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./jsx-types": { + "types": "./dist/jsx-types.d.ts", + "import": "./dist/jsx-types.js", + "require": "./dist/jsx-types.cjs" } }, "scripts": { - "build": "tsup src/index.ts --dts --format esm,cjs", + "build": "tsup src/index.ts src/jsx-types.ts --dts --format esm,cjs", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/packages/runtime/src/dom.ts b/packages/runtime/src/dom.ts index f323a49..1f349f9 100644 --- a/packages/runtime/src/dom.ts +++ b/packages/runtime/src/dom.ts @@ -157,6 +157,11 @@ function applyProps(element: Element, props: Props): void { continue; } + if (key === "ref" && typeof value === "function") { + value(element); + continue; + } + if (key.startsWith("on") && typeof value === "function") { element.addEventListener(key.slice(2).toLowerCase(), value as EventListener); continue; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b3bfc28..1f457a3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -2,4 +2,20 @@ export { createDOMNode, disposeNode } from "./dom"; export { ErrorBoundary } from "./error-boundary"; export { createProvider, Fragment, h } from "./jsx"; export type { Component, JSXDescriptor, Primitive, Props, ReactiveChild, Renderable, Tag } from "./jsx"; +export type { IntrinsicElements } from "./jsx-types"; export { mount } from "./render"; + +import type { JSXDescriptor } from "./jsx"; +import type { IntrinsicElements as RuntimeIntrinsicElements } from "./jsx-types"; + +declare global { + namespace JSX { + type Element = JSXDescriptor; + + interface ElementChildrenAttribute { + children: {}; + } + + interface IntrinsicElements extends RuntimeIntrinsicElements {} + } +} diff --git a/packages/runtime/src/jsx-types.ts b/packages/runtime/src/jsx-types.ts new file mode 100644 index 0000000..b159b63 --- /dev/null +++ b/packages/runtime/src/jsx-types.ts @@ -0,0 +1,105 @@ +import type { Accessor } from "@sarthakdev143/core"; + +import type { JSXDescriptor, Renderable } from "./jsx"; + +export type ReactiveOr = T | Accessor; +type StyleObject = Partial>; +type AttributePrimitive = boolean | number | string | null | undefined; + +interface DataAttributes { + [key: `aria-${string}`]: ReactiveOr; + [key: `data-${string}`]: ReactiveOr; +} + +interface EventHandlers { + onBlur?: (event: FocusEvent) => void; + onChange?: (event: Event) => void; + onClick?: (event: MouseEvent) => void; + onDblClick?: (event: MouseEvent) => void; + onFocus?: (event: FocusEvent) => void; + onInput?: (event: Event) => void; + onKeyDown?: (event: KeyboardEvent) => void; + onKeyUp?: (event: KeyboardEvent) => void; + onMouseEnter?: (event: MouseEvent) => void; + onMouseLeave?: (event: MouseEvent) => void; + onScroll?: (event: Event) => void; + onSubmit?: (event: SubmitEvent) => void; +} + +export interface BaseHTMLProps extends DataAttributes, EventHandlers { + children?: Renderable | Renderable[]; + class?: ReactiveOr; + className?: ReactiveOr; + hidden?: ReactiveOr; + id?: ReactiveOr; + key?: string | number; + ref?: (element: TElement) => void; + style?: ReactiveOr; + tabIndex?: ReactiveOr; + title?: ReactiveOr; +} + +export interface HTMLButtonProps extends BaseHTMLProps { + disabled?: ReactiveOr; + type?: ReactiveOr<"button" | "reset" | "submit">; +} + +export interface HTMLInputProps extends BaseHTMLProps { + checked?: ReactiveOr; + disabled?: ReactiveOr; + placeholder?: ReactiveOr; + type?: ReactiveOr; + value?: ReactiveOr; +} + +export interface HTMLTextAreaProps extends BaseHTMLProps { + disabled?: ReactiveOr; + placeholder?: ReactiveOr; + value?: ReactiveOr; +} + +export interface HTMLAnchorProps extends BaseHTMLProps { + href?: ReactiveOr; + target?: ReactiveOr; +} + +export interface HTMLImageProps extends BaseHTMLProps { + alt?: ReactiveOr; + src?: ReactiveOr; +} + +export interface HTMLLabelProps extends BaseHTMLProps { + htmlFor?: ReactiveOr; +} + +export interface HTMLFormProps extends BaseHTMLProps { + action?: ReactiveOr; + method?: ReactiveOr; +} + +export interface IntrinsicElements { + a: HTMLAnchorProps; + article: BaseHTMLProps; + aside: BaseHTMLProps; + button: HTMLButtonProps; + div: BaseHTMLProps; + footer: BaseHTMLProps; + form: HTMLFormProps; + h1: BaseHTMLProps; + h2: BaseHTMLProps; + h3: BaseHTMLProps; + header: BaseHTMLProps; + img: HTMLImageProps; + input: HTMLInputProps; + label: HTMLLabelProps; + li: BaseHTMLProps; + main: BaseHTMLProps; + nav: BaseHTMLProps; + ol: BaseHTMLProps; + p: BaseHTMLProps; + section: BaseHTMLProps; + span: BaseHTMLProps; + strong: BaseHTMLProps; + textarea: HTMLTextAreaProps; + ul: BaseHTMLProps; +} diff --git a/packages/runtime/src/jsx.ts b/packages/runtime/src/jsx.ts index 790dab1..8c9f353 100644 --- a/packages/runtime/src/jsx.ts +++ b/packages/runtime/src/jsx.ts @@ -1,4 +1,5 @@ import { provideContext, type Context } from "@sarthakdev143/core"; +import type { IntrinsicElements } from "./jsx-types"; export const Fragment = Symbol("ShadowFragment"); const providerMarker = Symbol("shadejs.provider"); @@ -8,10 +9,10 @@ export type Primitive = boolean | null | number | string | undefined; export type Props = Record & { key?: Key }; export type Renderable = Primitive | JSXDescriptor | ReactiveChild | Renderable[]; export type ReactiveChild = () => Renderable; -export type Component = { - bivarianceHack(props: Props & { children?: Renderable[] }): Renderable; +export type Component

= { + bivarianceHack(props: P & { children?: Renderable[] }): Renderable; }["bivarianceHack"]; -export type Tag = Component | typeof Fragment | string; +export type Tag = Component> | typeof Fragment | string; export type ProviderComponent = Component & { [providerMarker]?: Context; }; @@ -37,6 +38,12 @@ function flattenChildren(children: Renderable[]): Renderable[] { return flattened; } +export function h( + tag: K, + props: IntrinsicElements[K] | null, + ...children: Renderable[] +): JSXDescriptor; +export function h

(tag: Component

, props: P | null, ...children: Renderable[]): JSXDescriptor; export function h(tag: Tag, props: Props | null, ...children: Renderable[]): JSXDescriptor { return { children: flattenChildren(children), @@ -68,17 +75,3 @@ export function renderWithProvider(tag: Tag, props: Props, children: Renderab return provideContext(context, props.value, fn); } - -declare global { - namespace JSX { - type Element = JSXDescriptor; - - interface ElementChildrenAttribute { - children: {}; - } - - interface IntrinsicElements { - [elementName: string]: Props; - } - } -} diff --git a/packages/runtime/tests/jsx-types.test.ts b/packages/runtime/tests/jsx-types.test.ts new file mode 100644 index 0000000..b42f089 --- /dev/null +++ b/packages/runtime/tests/jsx-types.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { h } from "../src/index"; + +describe("@sarthakdev143/runtime JSX types", () => { + it("accepts valid div props", () => { + const node = h("div", { className: "foo" }, "hello"); + + expect(node.tag).toBe("div"); + expect(node.props.className).toBe("foo"); + }); + + it("rejects unknown div props at compile time", () => { + // @ts-expect-error invalid prop name should fail typechecking + h("div", { onClik: () => {} }); + + expect(true).toBe(true); + }); + + it("accepts input value props", () => { + const node = h("input", { value: "hello" }); + + expect(node.tag).toBe("input"); + expect(node.props.value).toBe("hello"); + }); + + it("accepts reactive button props", () => { + const node = h("button", { disabled: () => false }, "Save"); + + expect(node.tag).toBe("button"); + expect(typeof node.props.disabled).toBe("function"); + }); + + it("accepts anchor href props", () => { + const node = h("a", { href: "https://example.com" }, "Example"); + + expect(node.tag).toBe("a"); + expect(node.props.href).toBe("https://example.com"); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index bc7ad38..651d111 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,6 +11,7 @@ "@sarthakdev143/compiler": ["packages/compiler/src/index.ts"], "@sarthakdev143/core": ["packages/core/src/index.ts"], "@sarthakdev143/runtime": ["packages/runtime/src/index.ts"], + "@sarthakdev143/runtime/jsx-types": ["packages/runtime/src/jsx-types.ts"], "@sarthakdev143/state": ["packages/state/src/index.ts"] }, "jsx": "react", From 47c6af24d338b6f5b8919b246edf68a935d86327 Mon Sep 17 00:00:00 2001 From: sarthakdev143-lite Date: Sat, 21 Mar 2026 10:13:59 +0530 Subject: [PATCH 3/3] feat(runtime): add dev HMR support Injecting HMR acceptance in the compiler and swapping mounted component instances in the runtime keeps the local edit loop fast without changing production output. --- CHANGELOG.md | 2 + packages/compiler/src/analyzer.ts | 47 +++++++++++++++++++ packages/compiler/src/hmr.ts | 22 +++++++++ packages/compiler/src/index.ts | 3 +- packages/compiler/src/plugin.ts | 16 +++++-- packages/compiler/tests/compiler.test.ts | 58 +++++++++++++++++++++++- packages/runtime/src/dom.ts | 52 +++++++++++++++------ packages/runtime/src/hmr.ts | 55 ++++++++++++++++++++++ packages/runtime/src/index.ts | 1 + 9 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/src/hmr.ts create mode 100644 packages/runtime/src/hmr.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e85a922..eb35f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Context API helpers via `createContext`, `provideContext`, and `useContext` - Runtime `createProvider()` support for provider-backed component trees - Typed intrinsic element props for `h()` plus compile-time JSX prop coverage +- Dev-mode HMR export detection and component swap hooks for Vite ### Changed - Demo app now uses context for theme state instead of local prop wiring - Runtime package now exports `jsx-types` declarations for downstream tooling +- Exported runtime components can now be hot-swapped in place during local development ## [0.3.0] - 2026-03-21 diff --git a/packages/compiler/src/analyzer.ts b/packages/compiler/src/analyzer.ts index 9773707..3c2d489 100644 --- a/packages/compiler/src/analyzer.ts +++ b/packages/compiler/src/analyzer.ts @@ -1,10 +1,15 @@ import { parse, type ParserPlugin } from "@babel/parser"; import traverseImport, { type NodePath } from "@babel/traverse"; import { + isArrowFunctionExpression, + isExportNamedDeclaration, + isFunctionDeclaration, + isFunctionExpression, isIdentifier, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, + isVariableDeclaration, type ImportDeclaration, type ImportDefaultSpecifier, type ImportSpecifier @@ -62,3 +67,45 @@ export function analyzeServerImports(source: string): ServerImport[] { return imports; } + +export function extractExportedFunctions(source: string): string[] { + const ast = parse(source, { + plugins: parserPlugins, + sourceType: "module" + }); + const exportedFunctions = new Set(); + + traverse(ast, { + ExportNamedDeclaration(path) { + if (!isExportNamedDeclaration(path.node) || path.node.declaration === null) { + return; + } + + if (isFunctionDeclaration(path.node.declaration)) { + const functionId = path.node.declaration.id; + + if (functionId !== null && functionId !== undefined) { + exportedFunctions.add(functionId.name); + } + + return; + } + + if (!isVariableDeclaration(path.node.declaration)) { + return; + } + + for (const declarator of path.node.declaration.declarations) { + if ( + isIdentifier(declarator.id) && + declarator.init !== null && + (isArrowFunctionExpression(declarator.init) || isFunctionExpression(declarator.init)) + ) { + exportedFunctions.add(declarator.id.name); + } + } + } + }); + + return [...exportedFunctions]; +} diff --git a/packages/compiler/src/hmr.ts b/packages/compiler/src/hmr.ts new file mode 100644 index 0000000..1424c8c --- /dev/null +++ b/packages/compiler/src/hmr.ts @@ -0,0 +1,22 @@ +export function generateHMRBlock(componentExports: string[]): string { + if (componentExports.length === 0) { + return ""; + } + + const updates = componentExports + .map( + (name) => ` + if (newModule.${name} && window.__shadejs_registry__?.has("${name}")) { + window.__shadejs_registry__.get("${name}")(newModule.${name}); + }` + ) + .join(""); + + return ` +if (import.meta.hot) { + import.meta.hot.accept((newModule) => { + if (!newModule) return${updates} + }); +} +`; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 8a14839..cfcedbc 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,5 +1,6 @@ -export { analyzeServerImports, isServerImportPath } from "./analyzer"; +export { analyzeServerImports, extractExportedFunctions, isServerImportPath } from "./analyzer"; export type { ServerImport } from "./analyzer"; +export { generateHMRBlock } from "./hmr"; export { shadejs } from "./plugin"; export { generateRPCStub, getRPCRoutePath } from "./rpc-gen"; export { generateProductionServer } from "./server-build"; diff --git a/packages/compiler/src/plugin.ts b/packages/compiler/src/plugin.ts index 497c053..d79e309 100644 --- a/packages/compiler/src/plugin.ts +++ b/packages/compiler/src/plugin.ts @@ -4,7 +4,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Plugin, ViteDevServer } from "vite"; -import { analyzeServerImports } from "./analyzer"; +import { analyzeServerImports, extractExportedFunctions } from "./analyzer"; +import { generateHMRBlock } from "./hmr"; import { getRPCRoutePath } from "./rpc-gen"; import { generateProductionServer } from "./server-build"; import { transformServerImports } from "./transform"; @@ -116,6 +117,7 @@ function installRpcMiddleware(server: ViteDevServer, routeRegistry: Map(); return { @@ -128,6 +130,9 @@ export function shadejs(): Plugin { mkdirSync(outDir, { recursive: true }); writeFileSync(resolve(outDir, "server.mjs"), generateProductionServer(routeRegistry), "utf8"); }, + configResolved(config) { + command = config.command; + }, configureServer(server) { installRpcMiddleware(server, routeRegistry); }, @@ -149,12 +154,17 @@ export function shadejs(): Plugin { } const transformed = transformServerImports(code); + const nextCode = transformed?.code ?? code; + const hmrBlock = command === "serve" ? generateHMRBlock(extractExportedFunctions(nextCode)) : ""; - if (transformed === null) { + if (transformed === null && hmrBlock.length === 0) { return null; } - return transformed; + return { + code: `${nextCode}${hmrBlock.length > 0 ? `\n${hmrBlock.trimStart()}` : ""}`, + map: transformed?.map ?? null + }; } }; } diff --git a/packages/compiler/tests/compiler.test.ts b/packages/compiler/tests/compiler.test.ts index 29767d1..d49f189 100644 --- a/packages/compiler/tests/compiler.test.ts +++ b/packages/compiler/tests/compiler.test.ts @@ -1,11 +1,19 @@ import { parse } from "@babel/parser"; import { describe, expect, it } from "vitest"; -import { generateProductionServer } from "../src/index"; +import { extractExportedFunctions, generateHMRBlock, generateProductionServer } from "../src/index"; import { shadejs } from "../src/plugin"; import { generateRPCStub } from "../src/rpc-gen"; import { transformServerImports } from "../src/transform"; +function getConfigResolvedHook(plugin: ReturnType): ((config: { command: "build" | "serve" }) => void) | undefined { + if (typeof plugin.configResolved === "function") { + return plugin.configResolved as unknown as (config: { command: "build" | "serve" }) => void; + } + + return plugin.configResolved?.handler as unknown as ((config: { command: "build" | "serve" }) => void) | undefined; +} + describe("@sarthakdev143/compiler", () => { it("passes through files without .server imports", () => { const source = 'import { createSignal } from "@sarthakdev143/core";\n\nconst count = createSignal(0);'; @@ -68,6 +76,54 @@ describe("@sarthakdev143/compiler", () => { expect(result).toBeNull(); }); + it("generates an HMR block for exported components", () => { + const block = generateHMRBlock(["Feed", "Counter"]); + + expect(block).toContain('window.__shadejs_registry__?.has("Feed")'); + expect(block).toContain('window.__shadejs_registry__?.has("Counter")'); + expect(block).toContain("import.meta.hot.accept"); + }); + + it("extracts exported function components from source", () => { + const source = [ + "export function Feed() {", + ' return "feed";', + "}", + "export const Counter = () => 1;", + "const Hidden = () => 2;" + ].join("\n"); + + expect(extractExportedFunctions(source)).toEqual(["Feed", "Counter"]); + }); + + it("injects HMR only during serve mode", () => { + const source = 'export function Feed() { return "feed"; }'; + const servePlugin = shadejs(); + const serveConfigResolved = getConfigResolvedHook(servePlugin); + const serveTransform = + typeof servePlugin.transform === "function" + ? (servePlugin.transform as unknown as (code: string, id: string) => { code: string } | null) + : (servePlugin.transform?.handler as unknown as ((code: string, id: string) => { code: string } | null) | undefined); + + serveConfigResolved?.({ command: "serve" }); + const serveResult = serveTransform?.(source, "/src/feed.tsx"); + + expect(serveResult).not.toBeNull(); + expect(serveResult?.code).toContain("import.meta.hot"); + + const buildPlugin = shadejs(); + const buildConfigResolved = getConfigResolvedHook(buildPlugin); + const buildTransform = + typeof buildPlugin.transform === "function" + ? (buildPlugin.transform as unknown as (code: string, id: string) => { code: string } | null) + : (buildPlugin.transform?.handler as unknown as ((code: string, id: string) => { code: string } | null) | undefined); + + buildConfigResolved?.({ command: "build" }); + const buildResult = buildTransform?.(source, "/src/feed.tsx"); + + expect(buildResult).toBeNull(); + }); + it("generates a production RPC server source file", () => { const source = generateProductionServer(new Map([["posts", "/abs/posts.server.js"]])); diff --git a/packages/runtime/src/dom.ts b/packages/runtime/src/dom.ts index 1f349f9..e32ad00 100644 --- a/packages/runtime/src/dom.ts +++ b/packages/runtime/src/dom.ts @@ -1,6 +1,7 @@ import { createEffect } from "@sarthakdev143/core"; import { + type Component, Fragment, renderWithProvider, type JSXDescriptor, @@ -9,6 +10,7 @@ import { type ReactiveChild, type Renderable } from "./jsx"; +import { registerComponent } from "./hmr"; import { configureKeyedReconciler, reconcileKeyedList, type KeyedNode } from "./reconciler"; type DOMPropertyTarget = Element & Record; @@ -283,26 +285,48 @@ function createReactiveNodes(accessor: ReactiveChild): Node[] { } } +function renderComponentValue( + component: Component>, + props: Props, + children: Renderable[] +): Renderable { + return renderWithProvider(component, props, children, () => + component({ + ...props, + children + }) + ); +} + +function createComponentNodes( + component: Component>, + props: Props, + children: Renderable[] +): Node[] { + const anchor = document.createComment("shade-component"); + let currentComponent = component; + let currentState = createReactiveState(renderComponentValue(component, props, children)); + + if (component.name.length > 0) { + registerDisposer( + anchor, + registerComponent(component.name, (newFn) => { + currentComponent = newFn as Component>; + currentState = updateReactiveNodes(anchor, currentState, renderComponentValue(currentComponent, props, children)); + }) + ); + } + + return [...getStateNodes(currentState), anchor]; +} + function createElementNodes(descriptor: JSXDescriptor): Node[] { if (descriptor.tag === Fragment) { return descriptor.children.flatMap((child) => createDOMNodes(child)); } if (typeof descriptor.tag === "function") { - const component = descriptor.tag; - - return renderWithProvider( - component, - stripKeyProp(descriptor.props), - descriptor.children, - () => - createDOMNodes( - component({ - ...stripKeyProp(descriptor.props), - children: descriptor.children - }) - ) - ); + return createComponentNodes(descriptor.tag, stripKeyProp(descriptor.props), descriptor.children); } const element = document.createElement(descriptor.tag); diff --git a/packages/runtime/src/hmr.ts b/packages/runtime/src/hmr.ts new file mode 100644 index 0000000..2b04319 --- /dev/null +++ b/packages/runtime/src/hmr.ts @@ -0,0 +1,55 @@ +type ComponentUpdateListener = (newFn: Function) => void; + +interface ShadeHMRRegistry { + get: (name: string) => (newFn: Function) => void; + has: (name: string) => boolean; +} + +const registry = new Map>(); + +declare global { + interface Window { + __shadejs_registry__?: ShadeHMRRegistry; + } +} + +if (typeof window !== "undefined") { + window.__shadejs_registry__ = { + get(name: string) { + return (newFn: Function) => { + const listeners = registry.get(name); + + if (listeners === undefined) { + return; + } + + for (const listener of listeners) { + listener(newFn); + } + }; + }, + has(name: string) { + return registry.has(name); + } + }; +} + +export function registerComponent(name: string, onUpdate: ComponentUpdateListener): () => void { + const listeners = registry.get(name) ?? new Set(); + listeners.add(onUpdate); + registry.set(name, listeners); + + return () => { + const currentListeners = registry.get(name); + + if (currentListeners === undefined) { + return; + } + + currentListeners.delete(onUpdate); + + if (currentListeners.size === 0) { + registry.delete(name); + } + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1f457a3..13216d3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,5 +1,6 @@ export { createDOMNode, disposeNode } from "./dom"; export { ErrorBoundary } from "./error-boundary"; +export { registerComponent } from "./hmr"; export { createProvider, Fragment, h } from "./jsx"; export type { Component, JSXDescriptor, Primitive, Props, ReactiveChild, Renderable, Tag } from "./jsx"; export type { IntrinsicElements } from "./jsx-types";