partial-solidjs 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # partial-solidjs
2
+
3
+ State-preserving renderer for prefix-growing streaming Solid/TSX. Solid ships neither an HMR runtime nor a cross-render
4
+ reconciler, so this package owns both gaps: a `createSignal` shim that survives recompiles, and an in-place DOM reconcile
5
+ for append-only streamed frames.
6
+
7
+ ## Input
8
+
9
+ ```ts
10
+ import { SolidRenderer } from "partial-solidjs";
11
+
12
+ const renderer = await SolidRenderer.create(document.getElementById("preview"));
13
+ renderer.pushCode('import { createSignal } from "solid-js";\nexport default function App() {\n const [count, setCount] = createSignal(0);\n return <button onClick={() => setCount(count() + 1)}>{count()}');
14
+ ```
15
+
16
+ ## Output
17
+
18
+ - `createSolidTsxCompiler()` (Node, `@babel/core`) and `createBrowserSolidTsxCompiler()` (`@babel/standalone`) strip
19
+ TypeScript and run the Solid JSX transform in a single pass; `partial: true` completes the prefix through `partial-tsx`'s
20
+ `normalizeGeneratedTsx` first.
21
+ - `SolidRenderer` mounts via `solid-js/web`. `pushCode` appends a streaming frame, `render` replaces the buffer, and
22
+ `finish` installs one clean reactive root.
23
+
24
+ ## State preservation
25
+
26
+ `SolidStatePreserver` re-binds `createSignal` through a Blob-URL shim of `solid-js`, so the app's signals are created by
27
+ the renderer and a recompile reattaches them instead of resetting.
28
+
29
+ - Cells are indexed by source-order `createSignal(` call. A frame with the same signature reuses the previous cells; a
30
+ changed signature resets them.
31
+ - A cell is `touched` once the app calls its setter. Only touched cells keep their value — an untouched cell re-seeds
32
+ from the newest frame's initializer on every pass, so a truncated `createSignal("")` converges to the final value.
33
+ - Signals created after commit (lazy children) fall through to the real `createSignal` so they cannot alias a preserved
34
+ cell.
35
+
36
+ ## Corner Cases
37
+
38
+ - **Streaming reconcile.** Solid's effects capture their own fresh nodes, so swapping trees every token destroys hover,
39
+ focus and in-flight clicks. `patchFrame` reconciles instead: keep the committed nodes, patch their text/attributes, and
40
+ adopt the newly streamed-in tail. Gated on `isFramePatchable` (committed children are a structural prefix of the staged
41
+ ones) and on the frame reusing the same signal cells. `finish()` drops the kept root.
42
+ - **Visible-regression hold.** A streamed frame that drops text length or structural element count versus the committed
43
+ one is held back. Only a setter running inside a DOM interaction releases the hold — a timer or fetch callback cannot
44
+ commit a shorter partial.
45
+ - **Focus.** A full-swap commit replays focus via `captureFocusWithin` / `restoreFocusWithin` (child-index path plus text
46
+ selection). A structure-shifting frame can land the caret on a different element; that positional limit is accepted.
47
+ - **Signature blind spots.** `getSolidSignalSignature` masks comments/strings/templates first, but JSX text is lexed like
48
+ code (an apostrophe can open a phantom string and swallow a later `createSignal(`) and template literals are opaque
49
+ backtick-to-backtick. Tracked in #1208.
50
+
51
+ ## Test
52
+
53
+ ```sh
54
+ bun test lib/partial-solidjs
55
+ ```
package/package.json CHANGED
@@ -1,11 +1,35 @@
1
1
  {
2
2
  "name": "partial-solidjs",
3
- "version": "0.0.0",
4
- "description": "Placeholder for partial-solidjs",
5
- "main": "index.js",
6
- "scripts": {},
7
- "keywords": [],
8
- "author": "muspi-merol (https://www.npmjs.com/~muspi-merol)",
9
- "license": "UNLICENSED",
10
- "private": false
11
- }
3
+ "description": "A state-preserving renderer for prefix-growing streaming Solid/TSX.",
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/MindLab-Research/macaron-genui-demo",
9
+ "directory": "lib/partial-solidjs"
10
+ },
11
+ "exports": {
12
+ ".": "./src/runtime.ts",
13
+ "./compiler": "./src/compiler.ts",
14
+ "./browser-compiler": "./src/browser-compiler.ts"
15
+ },
16
+ "browser": {
17
+ "./src/nodeWasm.ts": false
18
+ },
19
+ "files": [
20
+ "src/**/*",
21
+ "!src/**/*.test.*"
22
+ ],
23
+ "dependencies": {
24
+ "@babel/core": "^7.29.7",
25
+ "@babel/preset-typescript": "^7.29.7",
26
+ "@babel/standalone": "^7.29.10",
27
+ "@esm.sh/tsx": "^1.5.3",
28
+ "babel-preset-solid": "^1.9.16",
29
+ "partial-tsx": "~0.0.5"
30
+ },
31
+ "peerDependencies": {
32
+ "solid-js": "^1.9.15"
33
+ },
34
+ "sideEffects": false
35
+ }
package/src/babel.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ declare module "@babel/core" {
2
+ export function transformAsync(code: string, options: Record<string, unknown>): Promise<{ code?: string | null } | null>;
3
+ }
4
+
5
+ declare module "@babel/preset-typescript" {
6
+ const preset: unknown;
7
+ export default preset;
8
+ }
9
+
10
+ declare module "babel-preset-solid" {
11
+ const preset: unknown;
12
+ export default preset;
13
+ }
14
+
15
+ declare module "@babel/standalone" {
16
+ export const availablePresets: Record<string, unknown>;
17
+ export function transform(code: string, options: Record<string, unknown>): { code?: string | null };
18
+ }
@@ -0,0 +1,42 @@
1
+ import { createSolidTsxCompilerWithTransform, SOLID_JSX_PRESET_OPTIONS, SOLID_TYPESCRIPT_PRESET_OPTIONS, type SolidTsxCompiler } from "./compilerCore";
2
+
3
+ type BabelStandalone = typeof import("@babel/standalone");
4
+
5
+ let babelPromise: Promise<BabelStandalone> | null = null;
6
+ let solidPresetPromise: Promise<unknown> | null = null;
7
+ // Null the cached promise on failure so a transient chunk-load error (deploy skew, flaky network) doesn't wedge the
8
+ // browser compiler for the whole session — same retry contract as compilerCore's initPromise.
9
+ const loadBabel = () =>
10
+ (babelPromise ??= import("@babel/standalone").catch((error) => {
11
+ babelPromise = null;
12
+ throw error;
13
+ }));
14
+ const loadSolidPreset = async () => {
15
+ const runtime = globalThis as { process?: { env?: Record<string, string | undefined> } };
16
+ // Babel 7 helper packages still read process.env at module init after Vite wraps the CJS preset for browsers.
17
+ runtime.process ??= { env: {} };
18
+ runtime.process.env ??= {};
19
+ const preset = (solidPresetPromise ??= import("babel-preset-solid").catch((error) => {
20
+ solidPresetPromise = null;
21
+ throw error;
22
+ }));
23
+ const module = (await preset) as { default?: unknown };
24
+ return module.default ?? module;
25
+ };
26
+ const transformSolidTsx = async (code: string, filename: string) => {
27
+ // compilerCore restores Error.prepareStackTrace around this call (@babel/standalone installs a global stack rewriter under Node).
28
+ const [Babel, solidPreset] = await Promise.all([loadBabel(), loadSolidPreset()]);
29
+ const result = Babel.transform(code, {
30
+ filename,
31
+ presets: [
32
+ [Babel.availablePresets.typescript, SOLID_TYPESCRIPT_PRESET_OPTIONS],
33
+ [solidPreset, SOLID_JSX_PRESET_OPTIONS],
34
+ ],
35
+ sourceType: "module",
36
+ });
37
+ return result.code;
38
+ };
39
+
40
+ export function createBrowserSolidTsxCompiler(): SolidTsxCompiler {
41
+ return createSolidTsxCompilerWithTransform(transformSolidTsx);
42
+ }
@@ -0,0 +1,9 @@
1
+ function assert(value, message) {
2
+ if (!value) throw new Error(message || "Assertion failed.");
3
+ }
4
+
5
+ assert.fail = function fail(message) {
6
+ throw new Error(message || "Assertion failed.");
7
+ };
8
+
9
+ module.exports = assert;
@@ -0,0 +1,23 @@
1
+ import { transformAsync } from "@babel/core";
2
+ import typescriptPreset from "@babel/preset-typescript";
3
+ import solidPreset from "babel-preset-solid";
4
+ import { createSolidTsxCompilerWithTransform, SOLID_JSX_PRESET_OPTIONS, SOLID_TYPESCRIPT_PRESET_OPTIONS, type SolidTsxCompiler } from "./compilerCore";
5
+
6
+ const transformSolidTsx = async (code: string, filename: string) => {
7
+ // compilerCore restores Error.prepareStackTrace around this call (Babel installs a global stack rewriter under Node).
8
+ const result = await transformAsync(code, {
9
+ filename,
10
+ presets: [
11
+ [typescriptPreset, SOLID_TYPESCRIPT_PRESET_OPTIONS],
12
+ [solidPreset, SOLID_JSX_PRESET_OPTIONS],
13
+ ],
14
+ sourceType: "module",
15
+ });
16
+ return result?.code;
17
+ };
18
+
19
+ export function createSolidTsxCompiler(): SolidTsxCompiler {
20
+ return createSolidTsxCompilerWithTransform(transformSolidTsx);
21
+ }
22
+
23
+ export type { CompileOptions, CompileResult, SolidTsxCompiler, RendererImportMap } from "./compilerCore";
@@ -0,0 +1,57 @@
1
+ import initTsx, { transform as transformTsx } from "@esm.sh/tsx";
2
+ import { normalizeGeneratedTsx } from "partial-tsx";
3
+
4
+ export type RendererImportMap = { imports?: Record<string, string>; scopes?: Record<string, Record<string, string>>; styles?: string[] };
5
+ export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string };
6
+ export type CompileResult = { code: string; source: string; changed: boolean };
7
+ export type SolidTsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
8
+ export type SolidTsxTransform = (code: string, filename: string) => Promise<string | null | undefined>;
9
+ // Shared so the Node (compiler.ts, tested) and browser (@babel/standalone) transform paths stay byte-identical; a
10
+ // one-sided edit to these options would make the two emit different output for the same source.
11
+ export const SOLID_TYPESCRIPT_PRESET_OPTIONS = { isTSX: true, allExtensions: true, onlyRemoveTypeImports: true };
12
+ export const SOLID_JSX_PRESET_OPTIONS = { generate: "dom" };
13
+ const textDecoder = new TextDecoder();
14
+
15
+ let initPromise: Promise<unknown> | null = null;
16
+ // A DOM shim in a Node test process still needs the on-disk WASM, not a browser asset URL.
17
+ const isNodeLike = typeof process !== "undefined" && Boolean((process as { versions?: { node?: string } }).versions?.node);
18
+ const initCompiler = () =>
19
+ (initPromise ??= (async () => {
20
+ try {
21
+ if (isNodeLike) return initTsx(await (await import("./nodeWasm")).loadNodeWasm());
22
+ } catch {
23
+ // Environments without a filesystem (e.g. web workers) fall back to wasm-bindgen's asset resolution below.
24
+ }
25
+ // No argument: wasm-bindgen resolves the neighboring .wasm asset without a Vite-only ?url import. #1690
26
+ return initTsx();
27
+ })().catch((error) => {
28
+ initPromise = null;
29
+ throw error;
30
+ }));
31
+ // Babel installs a process-global Error.prepareStackTrace rewriter. Under Node restore the native hook so Vite/Rollup error
32
+ // formatting and later standalone tests aren't polluted; in the browser (window defined) keep Babel's hook untouched.
33
+ const withNodeStackTraceRestored = async <T>(run: () => Promise<T>) => {
34
+ if (typeof window !== "undefined") return run();
35
+ const previous = Error.prepareStackTrace;
36
+ try {
37
+ return await run();
38
+ } finally {
39
+ if (previous === undefined) delete (Error as { prepareStackTrace?: typeof Error.prepareStackTrace }).prepareStackTrace;
40
+ else Error.prepareStackTrace = previous;
41
+ }
42
+ };
43
+
44
+ export function createSolidTsxCompilerWithTransform(transformSolidTsx: SolidTsxTransform): SolidTsxCompiler {
45
+ return {
46
+ async compile(code, options = {}) {
47
+ const filename = options.filename ?? "_.tsx";
48
+ const source = options.partial ? normalizeGeneratedTsx(code) : code;
49
+ // WASM init and the Solid/Babel transform are independent; overlap them so first compile doesn't serialize the two.
50
+ const [solidCode] = await Promise.all([withNodeStackTraceRestored(() => transformSolidTsx(source, filename)), initCompiler()]);
51
+ if (!solidCode) throw new Error("Solid JSX transform produced no code.");
52
+ const result = transformTsx({ filename: filename.replace(/\.tsx$/, ".ts"), code: solidCode, target: "es2022", importMap: options.importMap });
53
+ const compiled = textDecoder.decode(result.code);
54
+ return { code: compiled, source, changed: compiled !== options.previousCode };
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,45 @@
1
+ // commitFrame swaps the host's children wholesale (replaceChildren), so any focused input inside the preview loses focus
2
+ // and caret even though its Solid signal value is preserved. captureFocusWithin records the focused node by its child-index
3
+ // path from the host plus its text selection, and restoreFocusWithin replays it onto the freshly committed tree. This
4
+ // element-index path is a separate axis from SolidStatePreserver's createSignal-call-order keying — the two only coincide
5
+ // while structure is stable. A structure-shifting frame (a new sibling element before the focused node) can land the caret
6
+ // on a different element; that is the accepted limit of positional identity here.
7
+ type TextSelection = { start: number; end: number; direction: "forward" | "backward" | "none" };
8
+ export type CapturedFocus = { path: number[]; selection: TextSelection | null };
9
+
10
+ const isTextEntry = (node: Element): node is HTMLInputElement | HTMLTextAreaElement => {
11
+ if (node.tagName === "TEXTAREA") return true;
12
+ if (node.tagName !== "INPUT") return false;
13
+ // selectionStart throws on number/email/etc.; only these input types expose a text selection.
14
+ return /^(?:text|search|url|tel|password)$/.test((node as HTMLInputElement).type);
15
+ };
16
+ const readSelection = (node: Element): TextSelection | null => {
17
+ if (!isTextEntry(node) || node.selectionStart === null || node.selectionEnd === null) return null;
18
+ return { start: node.selectionStart, end: node.selectionEnd, direction: node.selectionDirection ?? "none" };
19
+ };
20
+
21
+ export const captureFocusWithin = (host: Element): CapturedFocus | null => {
22
+ const active = host.ownerDocument.activeElement;
23
+ if (!active || active === host || !host.contains(active)) return null;
24
+ const path: number[] = [];
25
+ for (let node: Element | null = active; node && node !== host; node = node.parentElement) {
26
+ const parent = node.parentElement;
27
+ if (!parent) return null;
28
+ path.unshift(Array.prototype.indexOf.call(parent.children, node));
29
+ }
30
+ return { path, selection: readSelection(active) };
31
+ };
32
+
33
+ const resolvePath = (host: Element, path: number[]): Element | null => {
34
+ let node: Element | null = host;
35
+ for (const index of path) node = node?.children[index] ?? null;
36
+ return node;
37
+ };
38
+
39
+ export const restoreFocusWithin = (host: Element, captured: CapturedFocus | null) => {
40
+ if (!captured) return;
41
+ const target = resolvePath(host, captured.path);
42
+ if (!(target instanceof host.ownerDocument.defaultView!.HTMLElement)) return;
43
+ target.focus();
44
+ if (captured.selection && isTextEntry(target)) target.setSelectionRange(captured.selection.start, captured.selection.end, captured.selection.direction);
45
+ };
package/src/morph.ts ADDED
@@ -0,0 +1,158 @@
1
+ // In-place DOM reconcile for streaming Solid frames.
2
+ //
3
+ // Solid has no cross-render reconciliation: each recompiled frame is a brand-new component whose fine-grained effects
4
+ // capture *its own* fresh nodes. Committing a frame by swapping in that fresh tree (`replaceChildren`) therefore
5
+ // destroys and recreates every node each token — so a tab the user is hovering loses its :hover ring on every frame
6
+ // (the flicker) and a mid-stream click lands on a node that was just replaced.
7
+ //
8
+ // Streaming is append-only: the source only grows, so each frame's DOM is the previous frame's DOM plus new trailing
9
+ // nodes and completed tails. That lets us *reconcile* instead of swap — pair existing nodes by index/tag, patch their
10
+ // text and attributes, adopt the newly streamed-in tail nodes, and discard the fresh frame's now-empty root. Existing
11
+ // nodes keep their identity (hover, focus, in-flight clicks, running CSS transitions all survive) while still tracking
12
+ // the stream. The kept live root keeps driving reactivity for the nodes it owns; adopted tail nodes are refreshed by
13
+ // the next frame's reconcile, and the final full render (on `finish`) installs one clean reactive root. This is the
14
+ // reconciliation partial-react gets for free from React's persistent root.
15
+
16
+ const isElement = (node: Node): node is HTMLElement => node.nodeType === 1;
17
+
18
+ const trackedEventListeners = Symbol("partial-solidjs:tracked-event-listeners");
19
+ type TrackedEventListener = {
20
+ type: string;
21
+ listener: EventListenerOrEventListenerObject;
22
+ options?: boolean | AddEventListenerOptions;
23
+ capture: boolean;
24
+ };
25
+ type TrackedEventTarget = EventTarget & { [trackedEventListeners]?: TrackedEventListener[] };
26
+ type EventTargetPrototype = {
27
+ addEventListener: EventTarget["addEventListener"];
28
+ removeEventListener: EventTarget["removeEventListener"];
29
+ };
30
+
31
+ // Native event listeners cannot be enumerated from a DOM node. A streaming frame is rendered into a temporary tree,
32
+ // then its kept nodes are reconciled against the previous tree; delegated Solid handlers are visible as `$$` expandos,
33
+ // but `on:click`/imperative listeners live only in the browser's listener table. Capture listeners while Solid stages
34
+ // a frame so morphing can transfer just those frame-owned listeners onto the reused node without touching listeners an
35
+ // embedding host attached itself (for example a test harness click probe).
36
+ let activeListenerCapture: object | null = null;
37
+
38
+ const hasEventListenerNodeShape = (value: EventTarget): value is TrackedEventTarget & HTMLElement => (value as Node).nodeType === 1;
39
+ const listenerCapture = (options: boolean | AddEventListenerOptions | undefined) => options === true || (typeof options === "object" && Boolean(options?.capture));
40
+ const getEventTargetPrototype = (target: EventTarget): EventTargetPrototype | null => {
41
+ let prototype = Object.getPrototypeOf(target) as EventTargetPrototype | null;
42
+ while (prototype && !Object.prototype.hasOwnProperty.call(prototype, "addEventListener")) prototype = Object.getPrototypeOf(prototype) as EventTargetPrototype | null;
43
+ return prototype;
44
+ };
45
+
46
+ const getTrackedListeners = (target: EventTarget) => (target as TrackedEventTarget)[trackedEventListeners];
47
+ const setTrackedListeners = (target: EventTarget, listeners: TrackedEventListener[] | undefined) => {
48
+ const node = target as TrackedEventTarget;
49
+ if (listeners?.length) node[trackedEventListeners] = listeners;
50
+ else delete node[trackedEventListeners];
51
+ };
52
+
53
+ /** Run a staged Solid render while recording native (non-delegated) listeners added by that frame. */
54
+ export const captureEventListeners = <T>(target: EventTarget, run: () => T) => {
55
+ const prototype = getEventTargetPrototype(target);
56
+ if (!prototype) return run();
57
+ const previousCapture = activeListenerCapture;
58
+ const originalAdd = prototype.addEventListener;
59
+ const originalRemove = prototype.removeEventListener;
60
+ const add = function (this: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
61
+ originalAdd.call(this, type, listener, options);
62
+ if (!activeListenerCapture || !listener || !hasEventListenerNodeShape(this)) return;
63
+ const listeners = getTrackedListeners(this) ?? [];
64
+ listeners.push({ type, listener, options, capture: listenerCapture(options) });
65
+ setTrackedListeners(this, listeners);
66
+ } as EventTarget["addEventListener"];
67
+ const remove = function (this: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) {
68
+ originalRemove.call(this, type, listener, options);
69
+ if (!activeListenerCapture || !listener || !hasEventListenerNodeShape(this)) return;
70
+ const capture = listenerCapture(options);
71
+ const listeners = getTrackedListeners(this)?.filter((entry) => !(entry.type === type && entry.listener === listener && entry.capture === capture));
72
+ setTrackedListeners(this, listeners);
73
+ } as EventTarget["removeEventListener"];
74
+ prototype.addEventListener = add;
75
+ prototype.removeEventListener = remove;
76
+ activeListenerCapture = {};
77
+ try {
78
+ return run();
79
+ } finally {
80
+ activeListenerCapture = previousCapture;
81
+ // A nested capture may have installed its own wrapper. Restore only this layer's wrapper, preserving any outer one.
82
+ if (prototype.addEventListener === add) prototype.addEventListener = originalAdd;
83
+ if (prototype.removeEventListener === remove) prototype.removeEventListener = originalRemove;
84
+ }
85
+ };
86
+
87
+ const syncNativeEventListeners = (from: HTMLElement, to: HTMLElement) => {
88
+ const previous = getTrackedListeners(from);
89
+ const next = getTrackedListeners(to);
90
+ if (!previous?.length && !next?.length) return;
91
+ for (const entry of previous ?? []) from.removeEventListener(entry.type, entry.listener, entry.capture);
92
+ for (const entry of next ?? []) from.addEventListener(entry.type, entry.listener, entry.options);
93
+ setTrackedListeners(
94
+ from,
95
+ next?.map((entry) => ({ ...entry })),
96
+ );
97
+ };
98
+
99
+ const sameShape = (a: Node, b: Node) => a.nodeType === b.nodeType && (!isElement(a) || a.tagName === (b as HTMLElement).tagName);
100
+
101
+ // A frame is reconcilable when the committed children are a structural prefix of the staged ones at every level:
102
+ // matching nodes pair up by index (append-only streams never reorder), and staged extras are newly streamed nodes we
103
+ // adopt. A committed level that is *longer* than the staged one means normalization retracted content; that needs the
104
+ // full-swap path (and its visible-regression gate) instead, so identity reuse only ever extends, never truncates.
105
+ const isNodePatchable = (current: Node, next: Node): boolean => {
106
+ if (!sameShape(current, next)) return false;
107
+ if (current.childNodes.length > next.childNodes.length) return false;
108
+ for (let i = 0; i < current.childNodes.length; i++) if (!isNodePatchable(current.childNodes[i], next.childNodes[i])) return false;
109
+ return true;
110
+ };
111
+ export const isFramePatchable = (current: ParentNode, next: ChildNode[]): boolean => {
112
+ if (current.childNodes.length > next.length) return false;
113
+ for (let i = 0; i < current.childNodes.length; i++) if (!isNodePatchable(current.childNodes[i], next[i])) return false;
114
+ return true;
115
+ };
116
+
117
+ const patchAttributes = (from: Element, to: Element) => {
118
+ for (const attr of to.attributes) if (from.getAttribute(attr.name) !== attr.value) from.setAttribute(attr.name, attr.value);
119
+ // Snapshot `from`'s attributes: the loop mutates it via removeAttribute, which would disturb a live iteration.
120
+ for (const attr of Array.from(from.attributes)) if (!to.hasAttribute(attr.name)) from.removeAttribute(attr.name);
121
+ };
122
+
123
+ // Solid delegates events by stashing the handler as a `$$event` expando on the node and reading it back at dispatch
124
+ // time through one document-level listener. A reused node keeps the handler from the frame that created it, so once a
125
+ // streamed tail *completes* a handler (e.g. an `onClick` that was mid-token last frame), we must copy the newest
126
+ // frame's expandos onto the kept node or the click fires nothing / a stale closure. All frames' handlers close over the
127
+ // same preserved signal cells, so the copied closure targets the right state.
128
+ // `Object.keys`, not `for...in`: Solid assigns these expandos directly onto the node, so they are always own
129
+ // properties, while `for...in` would additionally walk the whole `HTMLElement`/`Element`/`Node` prototype chain.
130
+ const syncDelegatedHandlers = (from: Record<string, unknown>, to: Record<string, unknown>) => {
131
+ for (const key of Object.keys(from)) if (key.startsWith("$$") && !(key in to)) delete from[key];
132
+ for (const key of Object.keys(to)) if (key.startsWith("$$")) from[key] = to[key];
133
+ };
134
+
135
+ const patchNode = (from: ChildNode, to: ChildNode) => {
136
+ if (!isElement(from) || !isElement(to)) {
137
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; // text or comment marker
138
+ return;
139
+ }
140
+ // Attributes and delegated handlers sync; DOM properties like input.value are left alone — controlled state belongs
141
+ // to the live root's own effects (over the same cells as the staged frame) and uncontrolled edits must survive.
142
+ patchAttributes(from, to);
143
+ syncDelegatedHandlers(from as unknown as Record<string, unknown>, to as unknown as Record<string, unknown>);
144
+ syncNativeEventListeners(from, to);
145
+ patchChildren(from, Array.from(to.childNodes));
146
+ };
147
+
148
+ // Appending moves a staged node out of its staging container into the live tree. That's safe: the moved node survives
149
+ // its origin root's disposal (only nodes still in staging are cleaned up), and until the stream ends the next frame's
150
+ // reconcile keeps it current.
151
+ const patchChildren = (current: ParentNode, next: ChildNode[]) => {
152
+ const paired = current.childNodes.length;
153
+ for (let i = 0; i < paired; i++) patchNode(current.childNodes[i], next[i]);
154
+ for (let i = paired; i < next.length; i++) current.appendChild(next[i]);
155
+ };
156
+
157
+ // Callers must have confirmed `isFramePatchable` first; the walk assumes every paired node lines up by index and tag.
158
+ export const patchFrame = patchChildren;
@@ -0,0 +1,10 @@
1
+ // Keep import.meta.resolve in a Node-only module: a CJS-emitting browser bundler cannot parse that syntax even when
2
+ // a runtime guard would skip it. The package's browser field excludes this module from browser builds. #1717
3
+ // Typed off globalThis so consumers of the raw .ts package do not need @types/bun.
4
+ const bun = (globalThis as { Bun?: { file(path: URL): { arrayBuffer(): Promise<ArrayBuffer> } } }).Bun;
5
+
6
+ export const loadNodeWasm = async () => {
7
+ const wasmUrl = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
8
+ if (bun) return bun.file(wasmUrl).arrayBuffer();
9
+ return (await import("node:fs/promises")).readFile(wasmUrl);
10
+ };
@@ -0,0 +1,190 @@
1
+ import { createSignal as solidCreateSignal } from "solid-js";
2
+ import type { RendererImportMap } from "./compilerCore";
3
+
4
+ type SignalSetter<T> = (value: T | ((previous: T) => T)) => T;
5
+ // `setter` is the app-facing setter (marks the cell touched); `rawSetter` is Solid's underlying setter used to re-seed an
6
+ // untouched cell from a fuller streamed initializer without flipping `touched`.
7
+ type SignalCell<T = unknown> = { getter: () => T; setter: SignalSetter<T>; rawSetter: SignalSetter<T>; touched: boolean };
8
+ type SolidSignalRuntime = { createSignal<T>(value: T, options?: unknown): [() => T, SignalSetter<T>] };
9
+ type MutationInteractionTracker = () => boolean;
10
+
11
+ let scopeSerial = 0;
12
+ const runtimeByScope = new Map<string, SolidSignalRuntime>();
13
+
14
+ const createScope = () => `solid-renderer:${(++scopeSerial).toString(36)}`;
15
+ const defineGlobalRuntime = () => {
16
+ const runtime = globalThis as typeof globalThis & { __partialSolidSignalRuntime?: Map<string, SolidSignalRuntime> };
17
+ runtime.__partialSolidSignalRuntime ??= runtimeByScope;
18
+ return runtime.__partialSolidSignalRuntime;
19
+ };
20
+
21
+ const skipQuotedString = (code: string, index: number) => {
22
+ const quote = code[index++];
23
+ while (index < code.length) {
24
+ if (code[index] === "\\") index += 2;
25
+ else if (code[index] === quote) return index + 1;
26
+ else index += 1;
27
+ }
28
+ return code.length;
29
+ };
30
+ // Replace comment / string / template spans with equal-length whitespace so a stray createSignal( token inside them cannot
31
+ // perturb the signal-count fingerprint. Regex-literal bodies stay as-is: telling them apart from division needs full
32
+ // expression context, and a createSignal( inside a regex effectively never appears in a generated component.
33
+ // Two more accepted blind spots of this token-level scan (tracked in #1208): JSX text is lexed like code, so an apostrophe
34
+ // in rendered copy (`<p>Don't…</p>`) opens a phantom string span that can swallow a later createSignal(; and template
35
+ // literals are opaque backtick-to-backtick, so a quote or backtick inside a `${}` interpolation shifts where the mask ends.
36
+ const maskNonCodeSpans = (code: string) => {
37
+ let out = "";
38
+ let runStart = 0;
39
+ const flushRun = (index: number) => {
40
+ if (index > runStart) out += code.slice(runStart, index);
41
+ };
42
+ for (let index = 0; index < code.length;) {
43
+ const char = code[index];
44
+ let stop: number;
45
+ if (char === "/" && code[index + 1] === "/") {
46
+ const end = code.indexOf("\n", index + 2);
47
+ stop = end === -1 ? code.length : end;
48
+ } else if (char === "/" && code[index + 1] === "*") {
49
+ const end = code.indexOf("*/", index + 2);
50
+ stop = end === -1 ? code.length : end + 2;
51
+ } else if (char === '"' || char === "'" || char === "`") {
52
+ stop = skipQuotedString(code, index);
53
+ } else {
54
+ // Ordinary code chars are copied verbatim in runs; only break the run at a comment/string/template opener.
55
+ index += 1;
56
+ continue;
57
+ }
58
+ flushRun(index);
59
+ out += " ".repeat(stop - index);
60
+ runStart = stop;
61
+ index = stop;
62
+ }
63
+ flushRun(code.length);
64
+ return out;
65
+ };
66
+ // Fingerprints a frame by its ordered createSignal(...) call count; beginRender keeps preserved cells while it holds and
67
+ // resets them when it changes. String#match has no lastIndex state, so one shared /g instance is safe across renders.
68
+ // Match the call-or-generic open `(` / `<` rather than a full `<…>`: a regex can't balance nested angle brackets, so
69
+ // spelling out the type argument would miscount createSignal<Map<K,V>> / createSignal<() => void> as zero.
70
+ const SOLID_SIGNAL_SIGNATURE_PATTERN = /\bcreateSignal\s*[(<]/g;
71
+ export const getSolidSignalSignature = (code: string) => String(maskNonCodeSpans(code).match(SOLID_SIGNAL_SIGNATURE_PATTERN)?.length ?? 0);
72
+
73
+ export class SolidStatePreserver {
74
+ private activeCells: SignalCell[] | null = null;
75
+ private cells: SignalCell[] = [];
76
+ private cursor = 0;
77
+ private mutationSerial = 0;
78
+ private mutationInteractionTracker: MutationInteractionTracker | null = null;
79
+ private renderingSignature = "";
80
+ private scope = createScope();
81
+ private shimSolidUrl = "";
82
+ private shimUrl: string | null = null;
83
+ private signature = "";
84
+
85
+ beginRender(sourceCode: string) {
86
+ const signature = getSolidSignalSignature(sourceCode);
87
+ this.activeCells = this.signature && this.signature !== signature ? [] : this.cells;
88
+ this.renderingSignature = signature;
89
+ this.cursor = 0;
90
+ defineGlobalRuntime().set(this.scope, this);
91
+ }
92
+
93
+ // True while the in-progress render reuses the committed signal cells (signature unchanged). The streaming DOM
94
+ // reconcile gates on this: reusing the live nodes is only safe when the frame binds the same cells. If a streamed
95
+ // token adds a createSignal, the signature changes and the cell array is rebuilt — the reconcile must then fall back
96
+ // to a full remount so the new cells drive fresh nodes instead of being stranded on the old tree.
97
+ isActiveCellsReused() {
98
+ return this.activeCells === this.cells;
99
+ }
100
+
101
+ // Monotonic count of app-facing setter calls that landed *between* renders during a DOM interaction. The streaming
102
+ // commit gate reads this: once the user has mutated state, a shorter next frame is no longer a bad partial but the
103
+ // legitimately smaller view of the new state, so the visible-regression hold must release instead of freezing the
104
+ // preview until the stream catches up. Background setters (timers, fetch callbacks, effects) do not release the gate.
105
+ getMutationSerial() {
106
+ return this.mutationSerial;
107
+ }
108
+
109
+ setMutationInteractionTracker(tracker: MutationInteractionTracker | null) {
110
+ this.mutationInteractionTracker = tracker;
111
+ }
112
+
113
+ commitRender() {
114
+ if (this.activeCells) this.cells = this.activeCells;
115
+ this.signature = this.renderingSignature;
116
+ this.activeCells = null;
117
+ this.renderingSignature = "";
118
+ this.cursor = 0;
119
+ }
120
+
121
+ abortRender() {
122
+ this.activeCells = null;
123
+ this.renderingSignature = "";
124
+ this.cursor = 0;
125
+ }
126
+
127
+ createSignal<T>(value: T, options?: unknown): [() => T, SignalSetter<T>] {
128
+ // Lazily mounted children can create signals after the frame commits. Those signals are outside the positional
129
+ // render cells; reusing this.cells[0] would alias the first preserved signal and overwrite its value.
130
+ if (!this.activeCells) return solidCreateSignal(value, options as never) as [() => T, SignalSetter<T>];
131
+ const index = this.cursor++;
132
+ const cells = this.activeCells;
133
+ let cell = cells[index] as SignalCell<T> | undefined;
134
+ if (!cell) {
135
+ const [getter, rawSetter] = solidCreateSignal(value, options as never) as [() => T, SignalSetter<T>];
136
+ const setter: SignalSetter<T> = (next) => {
137
+ cell!.touched = true;
138
+ // A setter call outside beginRender/commitRender only releases the visible-regression gate when it is running in
139
+ // a DOM interaction handler. Timers/fetch callbacks may update state too, but they do not make a shorter partial
140
+ // frame safe to commit while the source is still streaming.
141
+ if (!this.activeCells && this.mutationInteractionTracker?.()) this.mutationSerial += 1;
142
+ return rawSetter(next);
143
+ };
144
+ cell = { getter, setter, rawSetter, touched: false };
145
+ cells[index] = cell as SignalCell;
146
+ } else if (!cell.touched) {
147
+ // The initial value is part of the still-streaming template, not user state. A cell first seen mid-stream was seeded
148
+ // from a truncated initializer (e.g. `createSignal("")` before the string literal finished), so re-seed an untouched
149
+ // cell from the newest source each frame — the seed converges to the final value. Once the app calls the setter the
150
+ // cell is `touched` and its value is preserved across recompiles, keeping mid-stream edits alive.
151
+ cell.rawSetter(() => value);
152
+ }
153
+ return [cell.getter, cell.setter];
154
+ }
155
+
156
+ reset() {
157
+ this.activeCells = null;
158
+ this.cells = [];
159
+ this.cursor = 0;
160
+ this.renderingSignature = "";
161
+ this.signature = "";
162
+ }
163
+
164
+ dispose() {
165
+ defineGlobalRuntime().delete(this.scope);
166
+ this.reset();
167
+ if (this.shimUrl) URL.revokeObjectURL(this.shimUrl);
168
+ this.shimSolidUrl = "";
169
+ this.shimUrl = null;
170
+ this.scope = createScope();
171
+ }
172
+
173
+ patchImportMap(importmap?: RendererImportMap): RendererImportMap | undefined {
174
+ const solidUrl = importmap?.imports?.["solid-js"];
175
+ if (!solidUrl) return importmap;
176
+ return { ...importmap, imports: { ...importmap.imports, "solid-js": this.getShimUrl(solidUrl) } };
177
+ }
178
+
179
+ private getShimUrl(solidUrl: string) {
180
+ if (this.shimUrl && this.shimSolidUrl === solidUrl) return this.shimUrl;
181
+ if (this.shimUrl) URL.revokeObjectURL(this.shimUrl);
182
+ defineGlobalRuntime().set(this.scope, this);
183
+ const source = [`import * as solid from ${JSON.stringify(solidUrl)};`, `export * from ${JSON.stringify(solidUrl)};`, `const runtime = globalThis.__partialSolidSignalRuntime?.get(${JSON.stringify(this.scope)});`, `export const createSignal = runtime ? runtime.createSignal.bind(runtime) : solid.createSignal;`].join(
184
+ "\n",
185
+ );
186
+ this.shimSolidUrl = solidUrl;
187
+ this.shimUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
188
+ return this.shimUrl;
189
+ }
190
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,578 @@
1
+ import type { JSX } from "solid-js";
2
+ import type { CompileResult, SolidTsxCompiler } from "./compiler";
3
+ import { captureEventListeners, isFramePatchable, patchFrame } from "./morph";
4
+ import { SolidStatePreserver } from "./preserve";
5
+ import { captureFocusWithin, restoreFocusWithin } from "./focusBridge";
6
+ import type { RendererImportMap } from "./compilerCore";
7
+
8
+ export type SolidRenderPhase = "transform" | "compile" | "render";
9
+ export type SolidRendererCallbacks = { onRendered?: (component: unknown, code: string, serial?: number) => void; onError?: (error: Error, phase: SolidRenderPhase) => void };
10
+ export type SolidRendererClearOptions = { preserveVisualState?: boolean; preserveState?: boolean };
11
+ export type SolidRendererFlushMode = "microtask" | "immediate";
12
+ export type SolidRendererOptions = { importmap?: RendererImportMap; callbacks?: SolidRendererCallbacks; compiler?: SolidTsxCompiler; flushMode?: SolidRendererFlushMode };
13
+ export type SolidRendererUpdateMode = "push" | "render";
14
+ type SolidWebRuntime = { render: (code: () => JSX.Element, element: Element | Document | ShadowRoot | DocumentFragment | Node) => () => void };
15
+ type VisibleFrameMetrics = { structureCount: number; textLength: number };
16
+ type SolidPendingCommit = { target: HTMLElement; dispose: () => void; nodes: ChildNode[]; component: unknown; code: string; serial: number | undefined; compileSerial: number; sourceCode: string; cellsReused: boolean };
17
+
18
+ const attachedRenderer = Symbol.for("@macaron/partial-solidjs:attached-renderer");
19
+ type AttachableHost = HTMLElement & { [attachedRenderer]?: SolidRenderer };
20
+ const VISIBLE_FRAME_TAGS = ["input", "button", "select", "textarea", "img", "svg", "canvas", "video", "iframe"] as const;
21
+ // Capture user-facing events before Solid's delegated handlers run so only setters inside that dispatch release the
22
+ // visible-regression hold. A timer/fetch/effect setter runs after dispatch and must not turn a bad streamed frame into a
23
+ // committed one. The scope is `eventPhase !== NONE`, not a flag cleared by a microtask: for real (trusted) input the
24
+ // browser drains microtasks between listeners, so such a flag is already cleared when Solid's `document` handler runs.
25
+ const MUTATION_INTERACTION_EVENTS = [
26
+ "beforeinput",
27
+ "blur",
28
+ "change",
29
+ "click",
30
+ "compositionend",
31
+ "compositionstart",
32
+ "compositionupdate",
33
+ "contextmenu",
34
+ "copy",
35
+ "cut",
36
+ "dblclick",
37
+ "dragstart",
38
+ "drop",
39
+ "focus",
40
+ "input",
41
+ "keydown",
42
+ "keypress",
43
+ "keyup",
44
+ "mousedown",
45
+ "mouseup",
46
+ "paste",
47
+ "pointercancel",
48
+ "pointerdown",
49
+ "pointerup",
50
+ "reset",
51
+ "submit",
52
+ "touchcancel",
53
+ "touchend",
54
+ "touchstart",
55
+ "wheel",
56
+ ] as const;
57
+ // Links, ARIA widgets and editable hosts count as interactive too — a tab strip built from `[role=tab]` or an anchor-only
58
+ // nav would otherwise score structureCount 0, disabling the regression gate for exactly the markup it protects.
59
+ const visibleFrameSelector = `${VISIBLE_FRAME_TAGS.join(",")},a[href],[role=button],[role=tab],[contenteditable]:not([contenteditable="false"])`;
60
+ const structuralFrameSelector = `h1,h2,h3,h4,h5,h6,${visibleFrameSelector}`;
61
+ const normalizeError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
62
+ const loadBundledSolidWeb = async () => import("solid-js/web");
63
+ const hasVisibleFrameContent = (target: Element) => Boolean(target.textContent?.trim() || target.querySelector(visibleFrameSelector));
64
+ const getVisibleFrameMetrics = (target: Element): VisibleFrameMetrics => ({ structureCount: target.querySelectorAll(structuralFrameSelector).length, textLength: target.textContent?.trim().replace(/\s+/g, " ").length ?? 0 });
65
+ // A streamed prefix should never render *less* than the frame already committed: with initial state fixed during
66
+ // streaming, a longer prefix is a superset of a shorter one, so any drop is a transient bad partial. Hold the committed
67
+ // frame when EITHER metric regresses — an `&&` here would commit a frame that kept its text but dropped all interactive
68
+ // structure (structureCount 3→0), yanking the button out from under a mid-stream click.
69
+ const isVisibleFrameRegression = (next: VisibleFrameMetrics, current: VisibleFrameMetrics) => current.textLength > 0 && current.structureCount > 0 && (next.textLength < current.textLength || next.structureCount < current.structureCount);
70
+ const createDefaultCompiler = async () => {
71
+ if (typeof window !== "undefined") return (await import("./browser-compiler")).createBrowserSolidTsxCompiler();
72
+ return (await import(/* @vite-ignore */ "./compiler")).createSolidTsxCompiler();
73
+ };
74
+ // LRU cap on compiled components. Each entry pins a Blob-imported module the browser can't unload, so the bound keeps
75
+ // long streaming/playback sessions (one distinct frame per token) from growing memory without limit.
76
+ const COMPILED_COMPONENT_CACHE_LIMIT = 256;
77
+ const compiledComponentCache = new Map<string, unknown>();
78
+ const getCompiledComponent = (code: string) => {
79
+ const cached = compiledComponentCache.get(code);
80
+ if (cached) {
81
+ compiledComponentCache.delete(code);
82
+ compiledComponentCache.set(code, cached);
83
+ }
84
+ return cached;
85
+ };
86
+ const rememberCompiledComponent = (code: string, component: unknown) => {
87
+ compiledComponentCache.delete(code);
88
+ compiledComponentCache.set(code, component);
89
+ if (compiledComponentCache.size > COMPILED_COMPONENT_CACHE_LIMIT) compiledComponentCache.delete(compiledComponentCache.keys().next().value!);
90
+ };
91
+
92
+ export class SolidRenderer {
93
+ private callbacks: SolidRendererCallbacks;
94
+ private compiler?: SolidTsxCompiler;
95
+ private compilerPromise?: Promise<SolidTsxCompiler>;
96
+ private compileSerial = 0;
97
+ private committedMutationSerial = 0;
98
+ private currentBuffer = "";
99
+ private compileInFlight = false;
100
+ private queuedCompile: { partial: boolean; serial: number | undefined } | null = null;
101
+ private scheduledCompile: { partial: boolean; serial: number | undefined } | null = null;
102
+ private disposeSolid: (() => void) | null = null;
103
+ private flushMode: SolidRendererFlushMode;
104
+ private importmap?: RendererImportMap;
105
+ private lastComponentRendered = false;
106
+ private lastCommitReconciled = false;
107
+ private lastCompiledCode = "";
108
+ private lastComponent: unknown;
109
+ private moduleUrl: string | null = null;
110
+ private preserver = new SolidStatePreserver();
111
+ private pendingCommit: SolidPendingCommit | null = null;
112
+ private pointerClickFlushQueued = false;
113
+ private pointerClickSettling = false;
114
+ private pointerIds = new Set<number>();
115
+ private pointerSettleCancel: (() => void) | null = null;
116
+ private pointerTarget: HTMLElement | null = null;
117
+ private mutationInteractionEvent: Event | null = null;
118
+ private solidWebPromise?: Promise<SolidWebRuntime>;
119
+ private target: HTMLElement | null = null;
120
+ private updateMode: SolidRendererUpdateMode = "render";
121
+
122
+ private constructor(target: HTMLElement | null | undefined, options: SolidRendererOptions) {
123
+ this.callbacks = options.callbacks ?? {};
124
+ this.compiler = options.compiler;
125
+ this.flushMode = options.flushMode ?? "microtask";
126
+ this.importmap = options.importmap;
127
+ this.preserver.setMutationInteractionTracker(() => !!this.mutationInteractionEvent?.eventPhase);
128
+ if (target) this.attach(target);
129
+ }
130
+
131
+ static async create(target?: HTMLElement | null, options: SolidRendererOptions = {}) {
132
+ return new SolidRenderer(target, options);
133
+ }
134
+
135
+ attach(target: HTMLElement) {
136
+ const element = target as AttachableHost;
137
+ if (element[attachedRenderer] && element[attachedRenderer] !== this) element[attachedRenderer].detach();
138
+ const previous = this.target as AttachableHost | null;
139
+ if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
140
+ this.target = target;
141
+ element[attachedRenderer] = this;
142
+ this.bindPointerGuards(target);
143
+ return this;
144
+ }
145
+
146
+ detach() {
147
+ this.compileSerial += 1;
148
+ this.cancelScheduledCompile();
149
+ const previous = this.target as AttachableHost | null;
150
+ if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
151
+ this.unmount();
152
+ this.target = null;
153
+ this.unbindPointerGuards();
154
+ this.preserver.dispose();
155
+ this.revokeModuleUrl();
156
+ return this;
157
+ }
158
+
159
+ setFlushMode(value: SolidRendererFlushMode) {
160
+ this.flushMode = value;
161
+ return this;
162
+ }
163
+
164
+ setImportMap(importmap: RendererImportMap) {
165
+ this.importmap = importmap;
166
+ this.solidWebPromise = undefined;
167
+ this.lastComponentRendered = false;
168
+ return this;
169
+ }
170
+
171
+ render(code: string, serial?: number) {
172
+ this.currentBuffer = code;
173
+ this.updateMode = "render";
174
+ this.scheduleCompile(false, serial);
175
+ }
176
+
177
+ pushCode(code: string, serial?: number) {
178
+ this.currentBuffer += code;
179
+ this.updateMode = "push";
180
+ this.scheduleCompile(true, serial);
181
+ }
182
+
183
+ finish(code?: string, serial?: number) {
184
+ this.updateMode = "render";
185
+ if (code !== undefined) this.currentBuffer = code;
186
+ this.scheduleCompile(false, serial);
187
+ }
188
+
189
+ clear(options: SolidRendererClearOptions = {}) {
190
+ this.compileSerial += 1;
191
+ this.currentBuffer = "";
192
+ this.updateMode = "render";
193
+ this.cancelScheduledCompile();
194
+ this.lastCompiledCode = "";
195
+ this.lastComponent = undefined;
196
+ this.lastComponentRendered = false;
197
+ // preserveState is an opt-in for keeping generated createSignal cells across a stream restart, while
198
+ // preserveVisualState keeps the visible DOM. Keeping the full prior tree as the commit baseline can freeze a new
199
+ // stream: every smaller partial frame reads as a regression and gets held. Mock playback uses neither option so it
200
+ // starts from initial state; interactions within that run still survive subsequent push/replace-push frames.
201
+ if (!options.preserveState && !options.preserveVisualState) this.preserver.reset();
202
+ this.cancelPendingCommit();
203
+ if (!options.preserveVisualState) this.unmount();
204
+ this.revokeModuleUrl();
205
+ return this;
206
+ }
207
+
208
+ getCurrentBuffer() {
209
+ return this.currentBuffer;
210
+ }
211
+
212
+ getUpdateMode() {
213
+ return this.updateMode;
214
+ }
215
+
216
+ private unmount() {
217
+ this.cancelPendingCommit();
218
+ this.disposeSolid?.();
219
+ this.disposeSolid = null;
220
+ this.lastComponentRendered = false;
221
+ if (this.target) this.target.textContent = "";
222
+ }
223
+
224
+ private bindPointerGuards(target: HTMLElement) {
225
+ if (this.pointerTarget === target) return;
226
+ this.unbindPointerGuards();
227
+ this.pointerTarget = target;
228
+ target.addEventListener("pointerdown", this.handlePointerDown, true);
229
+ target.ownerDocument.addEventListener("pointerup", this.handlePointerEnd, true);
230
+ target.ownerDocument.addEventListener("pointercancel", this.handlePointerEnd, true);
231
+ target.ownerDocument.addEventListener("click", this.handleClickAfterPointer, true);
232
+ for (const type of MUTATION_INTERACTION_EVENTS) target.addEventListener(type, this.handleMutationInteraction, { capture: true, passive: true });
233
+ }
234
+
235
+ private unbindPointerGuards() {
236
+ const target = this.pointerTarget;
237
+ if (!target) return;
238
+ target.removeEventListener("pointerdown", this.handlePointerDown, true);
239
+ target.ownerDocument.removeEventListener("pointerup", this.handlePointerEnd, true);
240
+ target.ownerDocument.removeEventListener("pointercancel", this.handlePointerEnd, true);
241
+ target.ownerDocument.removeEventListener("click", this.handleClickAfterPointer, true);
242
+ for (const type of MUTATION_INTERACTION_EVENTS) target.removeEventListener(type, this.handleMutationInteraction, true);
243
+ this.pointerIds.clear();
244
+ this.endPointerClickSettle(false);
245
+ this.pointerTarget = null;
246
+ }
247
+
248
+ private handleMutationInteraction = (event: Event) => {
249
+ this.mutationInteractionEvent = event;
250
+ };
251
+
252
+ private handlePointerDown = (event: PointerEvent) => {
253
+ this.endPointerClickSettle(false);
254
+ this.pointerIds.add(event.pointerId);
255
+ };
256
+
257
+ private handlePointerEnd = (event: PointerEvent) => {
258
+ this.pointerIds.delete(event.pointerId);
259
+ if (this.pointerIds.size > 0) return;
260
+ this.beginPointerClickSettle();
261
+ };
262
+
263
+ private handleClickAfterPointer = () => {
264
+ if (!this.pointerClickSettling || this.pointerClickFlushQueued) return;
265
+ this.pointerClickFlushQueued = true;
266
+ this.clearPointerSettleFallback();
267
+ queueMicrotask(() => this.endPointerClickSettle(true));
268
+ };
269
+
270
+ private hasActivePointerInteraction() {
271
+ return this.pointerIds.size > 0 || this.pointerClickSettling;
272
+ }
273
+
274
+ private beginPointerClickSettle() {
275
+ this.pointerClickSettling = true;
276
+ this.pointerClickFlushQueued = false;
277
+ this.clearPointerSettleFallback();
278
+ const window = this.pointerTarget?.ownerDocument.defaultView;
279
+ if (window?.requestAnimationFrame && window.cancelAnimationFrame) {
280
+ // Prefer the click event as the ack. The frame fallback covers pointerup paths that do not synthesize a click.
281
+ const id = window.requestAnimationFrame(() => this.endPointerClickSettle(true));
282
+ this.pointerSettleCancel = () => window.cancelAnimationFrame(id);
283
+ return;
284
+ }
285
+ let cancelled = false;
286
+ queueMicrotask(() => {
287
+ if (!cancelled) this.endPointerClickSettle(true);
288
+ });
289
+ this.pointerSettleCancel = () => {
290
+ cancelled = true;
291
+ };
292
+ }
293
+
294
+ private clearPointerSettleFallback() {
295
+ this.pointerSettleCancel?.();
296
+ this.pointerSettleCancel = null;
297
+ }
298
+
299
+ private endPointerClickSettle(flush: boolean) {
300
+ this.clearPointerSettleFallback();
301
+ this.pointerClickFlushQueued = false;
302
+ this.pointerClickSettling = false;
303
+ if (flush) this.flushPendingCommit();
304
+ }
305
+
306
+ private cancelPendingCommit() {
307
+ this.pendingCommit?.dispose();
308
+ this.pendingCommit = null;
309
+ }
310
+
311
+ private commitFrame(frame: SolidPendingCommit) {
312
+ if (frame.compileSerial !== this.compileSerial || this.target !== frame.target) {
313
+ frame.dispose();
314
+ return;
315
+ }
316
+ // The freshly committed DOM now reflects any interaction that fired before this frame, so re-baseline the mutation
317
+ // serial: later frames of the same interaction resume normal regression gating instead of each re-triggering the
318
+ // release above.
319
+ this.committedMutationSerial = this.preserver.getMutationSerial();
320
+ // Element-reuse fast path for streaming frames. Append-only streaming means each partial frame's DOM is the last
321
+ // one extended, so instead of swapping in the fresh tree (which recreates every node — the source of the hover
322
+ // flicker and the mid-stream click miss) we reconcile: keep the committed nodes, patch their text/attributes, and
323
+ // adopt the newly streamed-in tail. A hovered tab keeps its :hover ring, focus/caret stay put with no focusBridge
324
+ // replay, an in-flight click keeps its target, and CSS transitions don't replay. Gated on the committed tree being
325
+ // a structural prefix of the fresh one and on the frame reusing the same signal cells — a cell-signature change (a
326
+ // new createSignal streamed in) needs the fresh root, so it takes the full-swap path below to re-establish a
327
+ // coherent reactive root. The kept root keeps driving its own nodes; adopted tail nodes are refreshed by the next
328
+ // frame's reconcile, and `finish` installs one clean full render. (partial-react never flickered because React's
329
+ // persistent reconciler reuses nodes; Solid has none, so we reconcile here.)
330
+ if (this.disposeSolid && this.updateMode === "push" && frame.cellsReused && isFramePatchable(frame.target, frame.nodes)) {
331
+ patchFrame(frame.target, frame.nodes);
332
+ frame.dispose();
333
+ this.lastComponentRendered = true;
334
+ this.lastCommitReconciled = true;
335
+ this.scheduleRenderedCallback(frame.component, frame.code, frame.serial, frame.compileSerial);
336
+ return;
337
+ }
338
+ const previousDispose = this.disposeSolid;
339
+ const capturedFocus = captureFocusWithin(frame.target);
340
+ previousDispose?.();
341
+ frame.target.replaceChildren(...frame.nodes);
342
+ restoreFocusWithin(frame.target, capturedFocus);
343
+ this.disposeSolid = () => {
344
+ frame.dispose();
345
+ frame.target.textContent = "";
346
+ };
347
+ this.lastComponentRendered = true;
348
+ this.lastCommitReconciled = false;
349
+ this.scheduleRenderedCallback(frame.component, frame.code, frame.serial, frame.compileSerial);
350
+ }
351
+
352
+ private flushPendingCommit() {
353
+ if (this.hasActivePointerInteraction()) return;
354
+ const frame = this.pendingCommit;
355
+ if (!frame) return;
356
+ this.pendingCommit = null;
357
+ // The staged DOM was rendered before the target click handler ran. Re-render from the preserved signal cells so the
358
+ // post-click state is what gets committed, instead of swapping in a stale pre-click frame.
359
+ frame.dispose();
360
+ void this.renderComponent(frame.component, frame.code, frame.serial, frame.compileSerial, frame.sourceCode);
361
+ }
362
+
363
+ private shouldCommitFrame(staged: HTMLElement, committed: HTMLElement) {
364
+ // Only the streaming append path gates on visible content; render()/finish() frames commit unconditionally, so the
365
+ // querySelectorAll/text scans below stay off the full-render hot path.
366
+ if (this.updateMode !== "push") return true;
367
+ if (!hasVisibleFrameContent(staged)) return false;
368
+ // The visible-regression hold assumes streaming state is fixed, so a shorter frame must be a transient bad partial.
369
+ // A user interaction (tab switch, toggle) breaks that: the committed DOM is a dead adopted tree that can't react, so
370
+ // the click's new — legitimately smaller — view arrives only through the next staged frame. Holding it would freeze
371
+ // the preview until the stream grew past the pre-click snapshot. Release once mutated so that frame commits (it takes
372
+ // the full-swap path, re-rooting a live interactive tree), then resume regression gating from the new baseline.
373
+ if (this.preserver.getMutationSerial() !== this.committedMutationSerial) return true;
374
+ // Partial TSX can briefly normalize to an earlier visible shell while the source is still append-only. Keep the
375
+ // fuller interactive DOM until the next frame catches up, otherwise clicks land on a regressed subtree.
376
+ return !isVisibleFrameRegression(getVisibleFrameMetrics(staged), getVisibleFrameMetrics(committed));
377
+ }
378
+
379
+ private cancelScheduledCompile() {
380
+ this.scheduledCompile = null;
381
+ this.queuedCompile = null;
382
+ }
383
+
384
+ private startCompile(partial: boolean, serial?: number) {
385
+ this.compileInFlight = true;
386
+ void this.compile(partial, serial).finally(() => {
387
+ this.compileInFlight = false;
388
+ const queued = this.queuedCompile;
389
+ this.queuedCompile = null;
390
+ if (queued) this.scheduleCompile(queued.partial, queued.serial);
391
+ });
392
+ }
393
+
394
+ private scheduleCompile(partial: boolean, serial?: number) {
395
+ if (this.compileInFlight) {
396
+ // Token streams can outrun the Solid compiler. Let append-only streaming commits land as intermediate frames,
397
+ // but keep only the latest queued buffer so we never stack one compile per token.
398
+ if (!partial) this.compileSerial += 1;
399
+ this.queuedCompile = { partial, serial };
400
+ return;
401
+ }
402
+ if (this.flushMode === "immediate") {
403
+ this.cancelScheduledCompile();
404
+ this.startCompile(partial, serial);
405
+ return;
406
+ }
407
+ if (this.scheduledCompile) {
408
+ this.scheduledCompile.partial = partial;
409
+ this.scheduledCompile.serial = serial;
410
+ return;
411
+ }
412
+ const scheduled = { partial, serial };
413
+ this.scheduledCompile = scheduled;
414
+ queueMicrotask(() => {
415
+ if (this.scheduledCompile !== scheduled) return;
416
+ this.scheduledCompile = null;
417
+ this.startCompile(scheduled.partial, scheduled.serial);
418
+ });
419
+ }
420
+
421
+ private async compile(partial: boolean, renderSerial?: number) {
422
+ const serial = (this.compileSerial += 1);
423
+ const code = this.currentBuffer;
424
+ let result: CompileResult;
425
+ try {
426
+ const compiler = this.compiler ?? (await this.getDefaultCompiler());
427
+ result = await compiler.compile(code, { importMap: this.preserver.patchImportMap(this.importmap), partial: partial && this.updateMode === "push", previousCode: this.lastCompiledCode });
428
+ } catch (error) {
429
+ if (serial !== this.compileSerial) return;
430
+ this.callbacks.onError?.(normalizeError(error), "transform");
431
+ return;
432
+ }
433
+ if (serial !== this.compileSerial) return;
434
+ // The streaming reconcile leaves controlled DOM properties (input/textarea `.value`, checkbox `.checked`) unsynced by
435
+ // design — it patches attributes/handlers but not properties, so uncontrolled edits survive. `finish` is meant to land
436
+ // one clean full render that re-establishes them. But when the final source compiles identically to the last streamed
437
+ // frame, `result.changed` is false; the callback-only branch would then skip that render and leave a textarea reading
438
+ // `value={draft()}` stuck on the reconcile's empty `.value`. Re-render from the same component when the last commit was
439
+ // a reconcile so the properties get re-established.
440
+ if (!result.changed && this.lastComponent) {
441
+ if (this.lastComponentRendered && !this.lastCommitReconciled) this.scheduleRenderedCallback(this.lastComponent, code, renderSerial, serial);
442
+ else await this.renderComponent(this.lastComponent, code, renderSerial, serial, result.source);
443
+ return;
444
+ }
445
+ try {
446
+ const { component, moduleUrl } = await this.importCompiledComponent(result.code);
447
+ if (serial !== this.compileSerial) {
448
+ if (moduleUrl) URL.revokeObjectURL(moduleUrl);
449
+ return;
450
+ }
451
+ this.lastCompiledCode = result.code;
452
+ this.lastComponent = component;
453
+ if (moduleUrl) this.adoptModuleUrl(moduleUrl);
454
+ else this.revokeModuleUrl();
455
+ await this.renderComponent(component, code, renderSerial, serial, result.source);
456
+ } catch (error) {
457
+ if (serial !== this.compileSerial) return;
458
+ this.callbacks.onError?.(normalizeError(error), "compile");
459
+ }
460
+ }
461
+
462
+ private async getDefaultCompiler() {
463
+ // Null the cached promise on failure so a transient compiler-chunk load error doesn't wedge this renderer until detach.
464
+ this.compilerPromise ??= createDefaultCompiler().catch((error) => {
465
+ this.compilerPromise = undefined;
466
+ throw error;
467
+ });
468
+ return this.compilerPromise;
469
+ }
470
+
471
+ private async getSolidWebRuntime() {
472
+ this.solidWebPromise ??= this.importmap?.imports?.["solid-js/web"] ? (import(/* @vite-ignore */ this.importmap.imports["solid-js/web"]) as Promise<SolidWebRuntime>) : loadBundledSolidWeb();
473
+ return this.solidWebPromise;
474
+ }
475
+
476
+ private async importCompiledComponent(code: string) {
477
+ const cached = getCompiledComponent(code);
478
+ if (cached) return { component: cached };
479
+ const moduleUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
480
+ try {
481
+ const module = (await import(/* @vite-ignore */ moduleUrl)) as { default?: unknown };
482
+ if (!module.default) throw new Error("No default export found in compiled module.");
483
+ rememberCompiledComponent(code, module.default);
484
+ return { component: module.default, moduleUrl };
485
+ } catch (error) {
486
+ URL.revokeObjectURL(moduleUrl);
487
+ throw error;
488
+ }
489
+ }
490
+
491
+ private adoptModuleUrl(moduleUrl: string) {
492
+ if (this.moduleUrl && this.moduleUrl !== moduleUrl) URL.revokeObjectURL(this.moduleUrl);
493
+ this.moduleUrl = moduleUrl;
494
+ }
495
+
496
+ private revokeModuleUrl() {
497
+ if (!this.moduleUrl) return;
498
+ URL.revokeObjectURL(this.moduleUrl);
499
+ this.moduleUrl = null;
500
+ }
501
+
502
+ private async renderComponent(component: unknown, code: string, serial: number | undefined, compileSerial: number, sourceCode = code) {
503
+ const target = this.target;
504
+ if (!target) return;
505
+ const Component = component as () => JSX.Element;
506
+ const stagingTarget = target.ownerDocument.createElement("div");
507
+ // Solid's top-level `insert` captures the render target as its parent. Keep that parent attached after commit by
508
+ // committing this div as a transparent wrapper; moving its children alone strands top-level reactive insertions
509
+ // on a detached div. `display: contents` keeps the wrapper out of layout while retaining the live parent node.
510
+ stagingTarget.style.display = "contents";
511
+ let nextDispose: (() => void) | null = null;
512
+ try {
513
+ const { render } = await this.getSolidWebRuntime();
514
+ if (compileSerial !== this.compileSerial || this.target !== target) return;
515
+ this.preserver.beginRender(sourceCode);
516
+ nextDispose = captureEventListeners(stagingTarget, () => render(() => Component(), stagingTarget));
517
+ if (compileSerial !== this.compileSerial || this.target !== target) {
518
+ nextDispose();
519
+ this.preserver.abortRender();
520
+ return;
521
+ }
522
+ const committedDispose = nextDispose;
523
+ const frame = {
524
+ target,
525
+ dispose: () => {
526
+ committedDispose();
527
+ stagingTarget.textContent = "";
528
+ },
529
+ nodes: [stagingTarget],
530
+ component,
531
+ code,
532
+ serial,
533
+ compileSerial,
534
+ sourceCode,
535
+ cellsReused: this.preserver.isActiveCellsReused(),
536
+ };
537
+ this.lastComponentRendered = false;
538
+ if (!this.shouldCommitFrame(stagingTarget, target)) {
539
+ frame.dispose();
540
+ this.preserver.abortRender();
541
+ this.scheduleRenderedCallback(component, code, serial, compileSerial);
542
+ return;
543
+ }
544
+ if (this.hasActivePointerInteraction()) {
545
+ this.preserver.abortRender();
546
+ this.cancelPendingCommit();
547
+ this.pendingCommit = frame;
548
+ // Defer the visual swap so an in-flight click keeps its target, but still ack. Ack-paced playback advances the
549
+ // stream on onRendered; withholding it here froze the whole stream until the pointer released — clicking a tab
550
+ // mid-stream made every tab look dead. flushPendingCommit lands the real commit on settle.
551
+ this.scheduleRenderedCallback(component, code, serial, compileSerial);
552
+ return;
553
+ }
554
+ this.preserver.commitRender();
555
+ this.cancelPendingCommit();
556
+ this.commitFrame(frame);
557
+ } catch (error) {
558
+ nextDispose?.();
559
+ this.preserver.abortRender();
560
+ if (compileSerial !== this.compileSerial) return;
561
+ this.callbacks.onError?.(normalizeError(error), "render");
562
+ }
563
+ }
564
+
565
+ private scheduleRenderedCallback(component: unknown, code: string, renderSerial: number | undefined, compileSerial: number) {
566
+ const notify = () => {
567
+ if (compileSerial === this.compileSerial) this.callbacks.onRendered?.(component, code, renderSerial);
568
+ };
569
+ // rAF is throttled/paused on a backgrounded tab, so ack-paced playback (onRendered drives the next chunk) would stall
570
+ // until refocus. Fire synchronously when hidden.
571
+ if (typeof document !== "undefined" && document.hidden) {
572
+ notify();
573
+ return;
574
+ }
575
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(notify);
576
+ else queueMicrotask(notify);
577
+ }
578
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- // Placeholder