Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ 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
- 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

### Added
Expand Down
28 changes: 25 additions & 3 deletions apps/demo/src/feed.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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();

Expand Down Expand Up @@ -48,9 +67,11 @@ async function handleAddPost(): Promise<void> {
}

export function Feed() {
const { theme } = useContext(ThemeContext);

return h(
"main",
{ className: "shell" },
{ className: "shell", "data-theme": theme },
h(
"section",
{ className: "hero panel" },
Expand All @@ -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" },
Expand All @@ -80,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",
Expand Down
20 changes: 18 additions & 2 deletions apps/demo/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
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");

if (!(app instanceof Element)) {
throw new Error("ShadeJS demo root element was not found.");
}

mount(Feed, app);
function App() {
const [theme, setTheme] = createSignal<ThemeMode>("dark");

return h(
ThemeProvider,
{
value: {
theme,
toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark"))
}
},
h(Feed, null)
);
}

mount(App, app);
73 changes: 73 additions & 0 deletions apps/demo/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ input {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
color: #f4efe7;
}

.panel {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions apps/demo/src/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, createProvider, type Accessor } from "@sarthakdev143/shadejs";

export type ThemeMode = "dark" | "light";

export interface ThemeContextValue {
theme: Accessor<ThemeMode>;
toggleTheme: () => void;
}

export const ThemeContext = createContext<ThemeContextValue>({
theme: () => "dark",
toggleTheme: () => {}
});

export const ThemeProvider = createProvider(ThemeContext);
39 changes: 39 additions & 0 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<symbol, unknown[]>();
const cleanupStack: CleanupScope[] = [];
const errorHandlerStack: ErrorHandler[] = [];

export interface Context<T> {
id: symbol;
defaultValue: T;
}

export function createContext<T>(defaultValue: T): Context<T> {
return {
defaultValue,
id: Symbol("shadejs.context")
};
}

export function provideContext<T, TResult>(context: Context<T>, 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<T>(context: Context<T>): 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<T>(observer: Computation, fn: () => T): T {
const previousObserver = currentObserver;
currentObserver = observer;
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
65 changes: 63 additions & 2 deletions packages/core/tests/signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";

import {
batch,
createContext,
createEffect,
createMemo,
createSignal,
Expand All @@ -10,8 +11,10 @@ import {
onCleanup,
onMount,
pendingEffects,
provideContext,
pushErrorHandler,
setEffectErrorHandler
setEffectErrorHandler,
useContext
} from "../src/index";

function waitForMicrotask(): Promise<void> {
Expand All @@ -28,7 +31,7 @@ afterEach(() => {
});
});

describe("@shadejs/core", () => {
describe("@sarthakdev143/core", () => {
it("reads and writes signals", () => {
const [count, setCount] = createSignal(0);

Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading
Loading