Here’s a pragmatic refactor plan that keeps behavior the same, trims circular deps, and pushes far more logic into pure, testable code. I’ll show the new boundaries, what moves where, and a few drop-in code snippets for the biggest wins (form init, “time.now”, hashing, component target parsing, etc.).
A. @hypernote/core (pure, no React / no stores)
-
Types:
Hypernote,HypernoteElement,RenderContext(but context is a plain data bag). -
Pure utilities:
resolveExpression,processString,resolveObjectVariables,applyPipes(no imports from React, stores, or network clients).deriveInitialFormData(content: Hypernote): Record<string,string>(scans elements forinput[type=hidden]and returns defaults).stableHash(obj): string(fast, sync hash of queries to avoidcrypto.subtleand async effects in UI).- Tiny “expression” support for
time.nowvia an injected clock rather thannew Function.
B. @hypernote/runtime (framework-agnostic orchestration)
-
Contracts (interfaces) that the app/adapters provide:
export interface QueryEngine { runAll(h: Hypernote, opts: { actionResults: Record<string,string>, onTriggerAction?: (name: string) => void, target?: TargetContext, parentExtracted?: Record<string,unknown> }): Promise<{ queryResults: Record<string,unknown[]>, extractedVariables: Record<string,unknown> }>; stream?: (/* optional streaming */) => () => void; } export interface ActionExecutor { execute(actionName: string, form: Record<string,string>): Promise<string|void>; } export interface TargetParser { parse(arg: string, kind: 0|1): Promise<TargetContext>; } export interface Clock { now(): number }
-
A pure planner that builds the query dependency order (DAG) from
content.queries. -
A small “state reducer” that merges
actionResults,queryResults,extractedVariables. All pure.
C. @hypernote/react (thin adapter)
-
useHypernote(content, services)hook that:- uses the runtime (not the renderer) to kick off queries.
- returns
{ queryResults, extractedVariables, loading, error, executeAction }.
-
HypernoteViewthat renders elements by calling pure render fns from core (no store, no network). -
ComponentWrappergets only adapter functions via props:targetParser,queryEngine,clock.
Adapters (your app):
snstrQueryEngine(snstrClient) implements QueryEnginenostrActionExecutor(relayHandler) implements ActionExecutornostrTargetParser(snstrClient) implements TargetParser- Provide
clock: { now: () => Date.now() } - Provide
pubkeyfromuseAuthStoreoutside the renderer.
This inversion eliminates the current cycles:
- Core → nothing.
- Runtime → Core.
- React → Core & Runtime.
- Adapters → external libs (nostr, stores), but not React.
What moves out of the renderer:
useNostrStore,useAuthStoreusage → parent collectssnstrClient+pubkeyand passes them viaservices/props.ComponentResolvercreation → a service you inject (or remove entirely if its only job is argument parsing; useTargetParser).crypto.subtle.digest(async) → replace with a syncstableHashin core.setTimeoutinside<input>path → replace with derived initial form state (pure), then re-init when content changes.
// @hypernote/react
export type Services = {
queryEngine: QueryEngine;
actionExecutor: ActionExecutor;
targetParser: TargetParser;
clock: Clock; // e.g. { now: () => Date.now() }
userPubkey: string | null; // injected, not read from a store here
};
export function HypernoteRenderer({
markdown,
services,
}: {
markdown: string;
services: Services;
}) {
const [debounced] = useDebounce(markdown || "", 300);
const compileResult = useMemo(() => safeCompileHypernote(debounced || ""), [debounced]);
return <RenderHypernoteContent content={compileResult.data} services={services} />;
}export function RenderHypernoteContent({
content,
services,
}: {
content: Hypernote;
services: Services;
}) {
// form init (pure → effect), no setTimeout
const initialForm = useMemo(() => deriveInitialFormData(content), [content]);
const [formData, setFormData] = useState<Record<string, string>>(initialForm);
useEffect(() => setFormData(initialForm), [initialForm]);
// queries hash (sync)
const queriesKey = useMemo(() => stableHash(content.queries || {}), [content.queries]);
// action results (ref)
const [publishedEventIds, setPublishedEventIds] = useState<Record<string, string>>({});
const { queryResults, extractedVariables, loading, error, executeAction: runAction } =
useHypernote(content, {
...services,
actionResults: publishedEventIds,
onTriggerAction: (name) => void runAction(name, formData),
queriesKey,
});
const ctx: RenderContext = {
queryResults,
extractedVariables,
formData,
events: content.events || {},
userPubkey: services.userPubkey,
loopVariables: {},
depth: 0,
loadingQueries: loading ? new Set(Object.keys(content.queries || {})) : new Set(),
onFormSubmit: (eventName) => void runAction(eventName, formData).then((id) => {
if (id) setPublishedEventIds((p) => ({ ...p, [eventName]: id }));
}),
onInputChange: (name, value) => setFormData((p) => ({ ...p, [name]: value })),
};
return (
<>
{error && (
<div style={{ background: "#fee", color: "#c00", padding: 10, borderRadius: 4 }}>
⚠️ Some data failed to load: {error}
</div>
)}
<div className="hypernote-content" style={content.style as React.CSSProperties}>
{content.elements?.map((el) => renderElement(el as any, ctx))}
</div>
</>
);
}4) Pure core fixes (remove hidden side effects & dangerous eval)
a) Hidden inputs (no setTimeout during render)
// @hypernote/core/forms.ts
export function deriveInitialFormData(h: Hypernote): Record<string, string> {
const acc: Record<string,string> = {};
const walk = (els?: HypernoteElement[]) => {
els?.forEach((el) => {
if (el.type === "input") {
const name = el.attributes?.name;
const type = el.attributes?.type || "text";
const val = el.attributes?.value || "";
if (name && type === "hidden" && acc[name] === undefined) acc[name] = val;
}
if (el.elements) walk(el.elements);
});
};
walk(h.elements);
return acc;
}Then the <input> renderer is a plain controlled input:
case 'input': {
const name = element.attributes?.name || '';
const type = element.attributes?.type || 'text';
const value = ctx.formData[name] ?? element.attributes?.value ?? '';
return (
<input
id={element.elementId}
style={element.style}
type={type}
name={name}
placeholder={element.attributes?.placeholder || ''}
value={value}
onChange={(e) => ctx.onInputChange(name, e.target.value)}
/>
);
}Inject a clock and support only simple tokens (time.now) + arithmetic:
// @hypernote/core/clock.ts
export type Clock = { now(): number }
// @hypernote/core/expr.ts
export function resolveExpression(expr: string, ctx: RenderContext, clock: Clock): unknown {
const clean = expr.startsWith('$') ? expr.slice(1) : expr;
// (1) Try variables/paths first (unchanged logic)...
const v = resolvePath(clean, ctx); // pure path resolver
if (v !== undefined) return v;
// (2) If expression contains time.now and arithmetic, allow limited eval
if (/\btime\.now\b/.test(expr)) {
const replaced = expr.replace(/\btime\.now\b/g, String(clock.now()));
if (!/[^0-9+\-*/().\s]/.test(replaced)) {
try {
// eslint-disable-next-line no-new-func
return Function(`"use strict";return (${replaced})`)();
} catch { /* fall through */ }
}
}
return undefined;
}Pass services.clock down to any resolver calls.
// @hypernote/core/hash.ts
export function stableHash(obj: unknown): string {
const s = JSON.stringify(obj ?? {});
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(16);
}- Before:
ComponentWrapperimportsuseNostrStore,parseTargetdirectly. - After: it receives
targetParser(injected) andclockvia props or from an upperservicesprop.
Sketch:
function ComponentWrapper({
element,
ctx,
services,
componentDef,
}: {
element: HypernoteElement & { alias?: string; argument?: string };
ctx: RenderContext;
services: Services; // includes targetParser, clock
componentDef: Hypernote; // already parsed once
}) {
const argResolved = useMemo(
() => (element.argument?.startsWith('{') ? processString(element.argument, ctx)
: String(resolveExpression(element.argument ?? '', ctx, services.clock))),
[element.argument, ctx, services.clock]
);
const [target, setTarget] = useState<TargetContext | null>(null);
const [loading, setLoading] = useState(componentDef.kind !== undefined);
const [err, setErr] = useState<string|null>(null);
useEffect(() => {
let cancelled = false;
const run = async () => {
if (componentDef.kind === undefined) { setLoading(false); return; }
if (!argResolved) { setErr('Waiting for data...'); setLoading(false); return; }
try {
setLoading(true);
const t = await services.targetParser.parse(argResolved, componentDef.kind as 0|1);
if (!cancelled) { setTarget(t); setErr(null); }
} catch (e: any) {
if (!cancelled) setErr(e?.message ?? 'Failed to parse target');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => { cancelled = true; };
}, [argResolved, componentDef.kind, services.targetParser]);
// ... rest unchanged; do not read any global stores
}Note: The component event → JSON parse should happen once (outside the component), not repeatedly inside render. Do that when you build
componentDeffrom the#aliasquery result.
Replace the big switch with a simple registry so files don’t import each other in cycles:
// @hypernote/core/registry.ts
export type ElementRenderer = (el: HypernoteElement, ctx: RenderContext, deps: { clock: Clock }) => React.ReactNode;
export const registry: Record<string, ElementRenderer> = {
h1: renderText, h2: renderText, p: renderText, code: renderText,
div: renderContainer, span: renderContainer,
form: renderForm, button: renderButton, input: renderInput,
img: renderImg, loop: renderLoop, if: renderIf, json: renderJson,
component: renderComponentShell, // very thin shim that renders <ComponentWrapper/>
};
export function renderElement(el: HypernoteElement, ctx: RenderContext, deps: { clock: Clock }) {
const r = registry[el.type] ?? renderUnknown;
return r(el, ctx, deps);
}Now renderElement is a single import point; each renderer fn is pure and tiny. No renderer imports the hook or stores.
- Remove
nip19import if unused. ComponentResolverbecomes an adapter you inject (or replace entirely withtargetParser).- Renderer never imports
useNostrStore,useAuthStore,useHypernoteExecutor. Only the React hook (useHypernote) imports the runtime (which imports core).
- Cycles: Renderer no longer imports anything that imports the renderer (stores/hooks/adapters sit “above” it).
- Purity:
resolveExpression,processString,deriveInitialFormData,stableHash, loop/if/json renderers are 100% pure and unit-testable. - Safety: No
setTimeoutduring render; nonew Functionon user strings (only limited arithmetic ontime.nowvia injected clock). - Performance: JSON parsing of kind-0 profiles no longer repeated on every property access; initial form defaults are calculated once.
- Flexibility: You can render the same
HypernoteViewin non-React environments by swapping the adapter (e.g., SSR, tests).
- Create
@hypernote/coreand move: types,resolveExpression,processString, pipes,deriveInitialFormData,stableHash. - Create
@hypernote/runtimewithQueryEngine,ActionExecutor,TargetParser,Clockcontracts and a pure planner (runAllis injected from adapters). - Move
useHypernoteExecutorlogic into@hypernote/react/useHypernoteand depend on runtime contracts (not on renderer). - Update
HypernoteRenderer/RenderHypernoteContentto acceptservices(pubkey, clock, adapters). - Replace the input hidden
setTimeoutwithderiveInitialFormData. - Replace
crypto.subtle.digestwithstableHash. - Pass
services.clockinto any resolver that needs time. - Replace direct store imports with data injection from the parent.
If you want, I can sketch a tiny PR diff for your repository structure next.