@constela/runtime 0.4.0 → 0.7.0

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/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ declare function createEffect(fn: EffectFn): () => void;
26
26
  interface StateStore {
27
27
  get(name: string): unknown;
28
28
  set(name: string, value: unknown): void;
29
+ subscribe(name: string, fn: (value: unknown) => void): () => void;
29
30
  }
30
31
  interface StateDefinition {
31
32
  type: string;
@@ -100,4 +101,28 @@ interface AppInstance {
100
101
  }
101
102
  declare function createApp(program: CompiledProgram, mount: HTMLElement): AppInstance;
102
103
 
103
- export { type ActionContext, type AppInstance, type EvaluationContext, type RenderContext, type Signal, type StateStore, createApp, createEffect, createSignal, createStateStore, evaluate, executeAction, render };
104
+ /**
105
+ * Hydrate - Attach to existing SSR-rendered HTML
106
+ *
107
+ * This module provides client-side hydration for server-rendered Constela apps.
108
+ * It attaches event handlers and enables reactivity without reconstructing the DOM.
109
+ */
110
+
111
+ /**
112
+ * Options for hydrating an SSR-rendered application
113
+ */
114
+ interface HydrateOptions {
115
+ /** The compiled program to hydrate */
116
+ program: CompiledProgram;
117
+ /** The container element containing SSR-rendered HTML */
118
+ container: HTMLElement;
119
+ }
120
+ /**
121
+ * Hydrates a server-rendered application.
122
+ *
123
+ * @param options - Hydration options containing program and container
124
+ * @returns AppInstance for controlling the hydrated application
125
+ */
126
+ declare function hydrateApp(options: HydrateOptions): AppInstance;
127
+
128
+ export { type ActionContext, type AppInstance, type EvaluationContext, type HydrateOptions, type RenderContext, type Signal, type StateStore, createApp, createEffect, createSignal, createStateStore, evaluate, executeAction, hydrateApp, render };
package/dist/index.js CHANGED
@@ -111,6 +111,13 @@ function createStateStore(definitions) {
111
111
  throw new Error(`State field "${name}" does not exist`);
112
112
  }
113
113
  signal.set(value);
114
+ },
115
+ subscribe(name, fn) {
116
+ const signal = signals.get(name);
117
+ if (!signal) {
118
+ throw new Error(`State field "${name}" does not exist`);
119
+ }
120
+ return signal.subscribe(fn);
114
121
  }
115
122
  };
116
123
  }
@@ -732,6 +739,410 @@ function createApp(program, mount) {
732
739
  }
733
740
  };
734
741
  }
742
+
743
+ // src/hydrate.ts
744
+ function isEventHandler2(value) {
745
+ return typeof value === "object" && value !== null && "event" in value && "action" in value;
746
+ }
747
+ function hydrateApp(options) {
748
+ const { program, container } = options;
749
+ const state = createStateStore(program.state);
750
+ let actions;
751
+ if (program.actions instanceof Map) {
752
+ actions = {};
753
+ program.actions.forEach((action, name) => {
754
+ actions[name] = action;
755
+ });
756
+ } else {
757
+ actions = program.actions;
758
+ }
759
+ const cleanups = [];
760
+ const ctx = {
761
+ state,
762
+ actions,
763
+ locals: {},
764
+ cleanups
765
+ };
766
+ const firstChild = container.firstElementChild;
767
+ if (firstChild) {
768
+ hydrate(program.view, firstChild, ctx);
769
+ }
770
+ let destroyed = false;
771
+ return {
772
+ destroy() {
773
+ if (destroyed) return;
774
+ destroyed = true;
775
+ for (const cleanup of cleanups) {
776
+ cleanup();
777
+ }
778
+ while (container.firstChild) {
779
+ container.removeChild(container.firstChild);
780
+ }
781
+ },
782
+ setState(name, value) {
783
+ if (destroyed) return;
784
+ state.set(name, value);
785
+ },
786
+ getState(name) {
787
+ return state.get(name);
788
+ }
789
+ };
790
+ }
791
+ function hydrate(node, domNode, ctx) {
792
+ switch (node.kind) {
793
+ case "element":
794
+ hydrateElement(node, domNode, ctx);
795
+ break;
796
+ case "text":
797
+ hydrateText(node, domNode, ctx);
798
+ break;
799
+ case "if":
800
+ hydrateIf(node, domNode, ctx);
801
+ break;
802
+ case "each":
803
+ hydrateEach(node, domNode, ctx);
804
+ break;
805
+ case "markdown":
806
+ case "code":
807
+ break;
808
+ default:
809
+ break;
810
+ }
811
+ }
812
+ function hydrateElement(node, el, ctx) {
813
+ if (node.props) {
814
+ for (const [propName, propValue] of Object.entries(node.props)) {
815
+ if (isEventHandler2(propValue)) {
816
+ const handler = propValue;
817
+ const eventName = handler.event;
818
+ el.addEventListener(eventName, async (event) => {
819
+ const action = ctx.actions[handler.action];
820
+ if (action) {
821
+ const eventLocals = {};
822
+ const target = event.target;
823
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
824
+ eventLocals["value"] = target.value;
825
+ if (target instanceof HTMLInputElement && target.type === "checkbox") {
826
+ eventLocals["checked"] = target.checked;
827
+ }
828
+ }
829
+ let payload = void 0;
830
+ if (handler.payload) {
831
+ payload = evaluate(handler.payload, {
832
+ state: ctx.state,
833
+ locals: { ...ctx.locals, ...eventLocals }
834
+ });
835
+ }
836
+ const actionCtx = {
837
+ state: ctx.state,
838
+ actions: ctx.actions,
839
+ locals: { ...ctx.locals, ...eventLocals, payload },
840
+ eventPayload: payload
841
+ };
842
+ await executeAction(action, actionCtx);
843
+ }
844
+ });
845
+ } else {
846
+ const cleanup = createEffect(() => {
847
+ const value = evaluate(propValue, {
848
+ state: ctx.state,
849
+ locals: ctx.locals
850
+ });
851
+ applyProp2(el, propName, value);
852
+ });
853
+ ctx.cleanups.push(cleanup);
854
+ }
855
+ }
856
+ }
857
+ if (node.children) {
858
+ hydrateChildren(node.children, el, ctx);
859
+ }
860
+ }
861
+ function hydrateChildren(children, parent, ctx) {
862
+ const domChildren = [];
863
+ for (let i = 0; i < parent.childNodes.length; i++) {
864
+ const child = parent.childNodes[i];
865
+ if (child.nodeType !== Node.COMMENT_NODE) {
866
+ domChildren.push(child);
867
+ }
868
+ }
869
+ let domIndex = 0;
870
+ for (let i = 0; i < children.length; i++) {
871
+ const childNode = children[i];
872
+ if (childNode.kind === "text") {
873
+ const textNodes = [childNode];
874
+ while (i + 1 < children.length && children[i + 1].kind === "text") {
875
+ i++;
876
+ textNodes.push(children[i]);
877
+ }
878
+ const domChild = domChildren[domIndex];
879
+ if (domChild && domChild.nodeType === Node.TEXT_NODE) {
880
+ hydrateTextGroup(textNodes, domChild, ctx);
881
+ domIndex++;
882
+ }
883
+ } else if (childNode.kind === "element") {
884
+ const domChild = domChildren[domIndex];
885
+ if (domChild && domChild.nodeType === Node.ELEMENT_NODE) {
886
+ hydrate(childNode, domChild, ctx);
887
+ domIndex++;
888
+ }
889
+ } else if (childNode.kind === "if") {
890
+ const domChild = domChildren[domIndex];
891
+ if (domChild) {
892
+ hydrate(childNode, domChild, ctx);
893
+ domIndex++;
894
+ }
895
+ } else if (childNode.kind === "each") {
896
+ const items = evaluate(childNode.items, {
897
+ state: ctx.state,
898
+ locals: ctx.locals
899
+ });
900
+ const itemCount = Array.isArray(items) ? items.length : 0;
901
+ if (itemCount > 0) {
902
+ const firstDomChild = domChildren[domIndex];
903
+ if (firstDomChild) {
904
+ hydrate(childNode, firstDomChild, ctx);
905
+ domIndex += itemCount;
906
+ }
907
+ }
908
+ } else {
909
+ const domChild = domChildren[domIndex];
910
+ if (domChild) {
911
+ hydrate(childNode, domChild, ctx);
912
+ domIndex++;
913
+ }
914
+ }
915
+ }
916
+ }
917
+ function hydrateTextGroup(nodes, textNode, ctx) {
918
+ const cleanup = createEffect(() => {
919
+ let combinedText = "";
920
+ for (const node of nodes) {
921
+ const value = evaluate(node.value, {
922
+ state: ctx.state,
923
+ locals: ctx.locals
924
+ });
925
+ combinedText += formatValue2(value);
926
+ }
927
+ textNode.textContent = combinedText;
928
+ });
929
+ ctx.cleanups.push(cleanup);
930
+ }
931
+ function applyProp2(el, propName, value) {
932
+ if (propName === "className") {
933
+ el.className = String(value ?? "");
934
+ } else if (propName === "style" && typeof value === "string") {
935
+ el.setAttribute("style", value);
936
+ } else if (propName === "disabled") {
937
+ if (value) {
938
+ el.setAttribute("disabled", "disabled");
939
+ el.disabled = true;
940
+ } else {
941
+ el.removeAttribute("disabled");
942
+ el.disabled = false;
943
+ }
944
+ } else if (propName === "value" && el instanceof HTMLInputElement) {
945
+ el.value = String(value ?? "");
946
+ } else if (propName.startsWith("data-")) {
947
+ el.setAttribute(propName, String(value ?? ""));
948
+ } else {
949
+ if (value === true) {
950
+ el.setAttribute(propName, "");
951
+ } else if (value === false || value === null || value === void 0) {
952
+ el.removeAttribute(propName);
953
+ } else {
954
+ el.setAttribute(propName, String(value));
955
+ }
956
+ }
957
+ }
958
+ function hydrateText(node, textNode, ctx) {
959
+ const cleanup = createEffect(() => {
960
+ const value = evaluate(node.value, {
961
+ state: ctx.state,
962
+ locals: ctx.locals
963
+ });
964
+ textNode.textContent = formatValue2(value);
965
+ });
966
+ ctx.cleanups.push(cleanup);
967
+ }
968
+ function formatValue2(value) {
969
+ if (value === null || value === void 0) {
970
+ return "";
971
+ }
972
+ if (typeof value === "object") {
973
+ return JSON.stringify(value);
974
+ }
975
+ return String(value);
976
+ }
977
+ function hydrateIf(node, initialDomNode, ctx) {
978
+ const anchor = document.createComment("if");
979
+ const parent = initialDomNode.parentNode;
980
+ if (!parent) return;
981
+ parent.insertBefore(anchor, initialDomNode);
982
+ let currentNode = initialDomNode;
983
+ let currentBranch = "none";
984
+ let branchCleanups = [];
985
+ let isFirstRun = true;
986
+ const initialCondition = evaluate(node.condition, {
987
+ state: ctx.state,
988
+ locals: ctx.locals
989
+ });
990
+ currentBranch = Boolean(initialCondition) ? "then" : node.else ? "else" : "none";
991
+ if (currentBranch === "then") {
992
+ const localCleanups = [];
993
+ const branchCtx = { ...ctx, cleanups: localCleanups };
994
+ hydrate(node.then, currentNode, branchCtx);
995
+ branchCleanups = localCleanups;
996
+ } else if (currentBranch === "else" && node.else) {
997
+ const localCleanups = [];
998
+ const branchCtx = { ...ctx, cleanups: localCleanups };
999
+ hydrate(node.else, currentNode, branchCtx);
1000
+ branchCleanups = localCleanups;
1001
+ }
1002
+ const effectCleanup = createEffect(() => {
1003
+ const condition = evaluate(node.condition, {
1004
+ state: ctx.state,
1005
+ locals: ctx.locals
1006
+ });
1007
+ const shouldShowThen = Boolean(condition);
1008
+ const newBranch = shouldShowThen ? "then" : node.else ? "else" : "none";
1009
+ if (isFirstRun) {
1010
+ isFirstRun = false;
1011
+ return;
1012
+ }
1013
+ if (newBranch !== currentBranch) {
1014
+ for (const cleanup of branchCleanups) {
1015
+ cleanup();
1016
+ }
1017
+ branchCleanups = [];
1018
+ if (currentNode && currentNode.parentNode) {
1019
+ currentNode.parentNode.removeChild(currentNode);
1020
+ }
1021
+ const localCleanups = [];
1022
+ const branchCtx = {
1023
+ state: ctx.state,
1024
+ actions: ctx.actions,
1025
+ locals: ctx.locals,
1026
+ cleanups: localCleanups
1027
+ };
1028
+ if (newBranch === "then") {
1029
+ currentNode = render(node.then, branchCtx);
1030
+ branchCleanups = localCleanups;
1031
+ } else if (newBranch === "else" && node.else) {
1032
+ currentNode = render(node.else, branchCtx);
1033
+ branchCleanups = localCleanups;
1034
+ } else {
1035
+ currentNode = null;
1036
+ }
1037
+ if (currentNode && anchor.parentNode) {
1038
+ anchor.parentNode.insertBefore(currentNode, anchor.nextSibling);
1039
+ }
1040
+ currentBranch = newBranch;
1041
+ }
1042
+ });
1043
+ ctx.cleanups.push(effectCleanup);
1044
+ ctx.cleanups.push(() => {
1045
+ for (const cleanup of branchCleanups) {
1046
+ cleanup();
1047
+ }
1048
+ });
1049
+ }
1050
+ function hydrateEach(node, firstItemDomNode, ctx) {
1051
+ const parent = firstItemDomNode.parentNode;
1052
+ if (!parent) return;
1053
+ const anchor = document.createComment("each");
1054
+ parent.insertBefore(anchor, firstItemDomNode);
1055
+ let currentNodes = [];
1056
+ let itemCleanups = [];
1057
+ const initialItems = evaluate(node.items, {
1058
+ state: ctx.state,
1059
+ locals: ctx.locals
1060
+ });
1061
+ let isFirstRun = true;
1062
+ if (Array.isArray(initialItems) && initialItems.length > 0) {
1063
+ let domNode = firstItemDomNode;
1064
+ initialItems.forEach((item, index) => {
1065
+ if (!domNode) return;
1066
+ currentNodes.push(domNode);
1067
+ const itemLocals = {
1068
+ ...ctx.locals,
1069
+ [node.as]: item
1070
+ };
1071
+ if (node.index) {
1072
+ itemLocals[node.index] = index;
1073
+ }
1074
+ const localCleanups = [];
1075
+ const itemCtx = {
1076
+ ...ctx,
1077
+ locals: itemLocals,
1078
+ cleanups: localCleanups
1079
+ };
1080
+ hydrate(node.body, domNode, itemCtx);
1081
+ itemCleanups.push(...localCleanups);
1082
+ domNode = domNode.nextSibling;
1083
+ while (domNode && domNode.nodeType === Node.COMMENT_NODE) {
1084
+ domNode = domNode.nextSibling;
1085
+ }
1086
+ });
1087
+ }
1088
+ const effectCleanup = createEffect(() => {
1089
+ const items = evaluate(node.items, {
1090
+ state: ctx.state,
1091
+ locals: ctx.locals
1092
+ });
1093
+ if (isFirstRun) {
1094
+ isFirstRun = false;
1095
+ return;
1096
+ }
1097
+ for (const cleanup of itemCleanups) {
1098
+ cleanup();
1099
+ }
1100
+ itemCleanups = [];
1101
+ for (const oldNode of currentNodes) {
1102
+ if (oldNode.parentNode) {
1103
+ oldNode.parentNode.removeChild(oldNode);
1104
+ }
1105
+ }
1106
+ currentNodes = [];
1107
+ if (Array.isArray(items)) {
1108
+ items.forEach((item, index) => {
1109
+ const itemLocals = {
1110
+ ...ctx.locals,
1111
+ [node.as]: item
1112
+ };
1113
+ if (node.index) {
1114
+ itemLocals[node.index] = index;
1115
+ }
1116
+ const localCleanups = [];
1117
+ const itemCtx = {
1118
+ state: ctx.state,
1119
+ actions: ctx.actions,
1120
+ locals: itemLocals,
1121
+ cleanups: localCleanups
1122
+ };
1123
+ const itemNode = render(node.body, itemCtx);
1124
+ currentNodes.push(itemNode);
1125
+ itemCleanups.push(...localCleanups);
1126
+ if (anchor.parentNode) {
1127
+ let refNode = anchor.nextSibling;
1128
+ if (currentNodes.length > 1) {
1129
+ const lastExisting = currentNodes[currentNodes.length - 2];
1130
+ if (lastExisting) {
1131
+ refNode = lastExisting.nextSibling;
1132
+ }
1133
+ }
1134
+ anchor.parentNode.insertBefore(itemNode, refNode);
1135
+ }
1136
+ });
1137
+ }
1138
+ });
1139
+ ctx.cleanups.push(effectCleanup);
1140
+ ctx.cleanups.push(() => {
1141
+ for (const cleanup of itemCleanups) {
1142
+ cleanup();
1143
+ }
1144
+ });
1145
+ }
735
1146
  export {
736
1147
  createApp,
737
1148
  createEffect,
@@ -739,5 +1150,6 @@ export {
739
1150
  createStateStore,
740
1151
  evaluate,
741
1152
  executeAction,
1153
+ hydrateApp,
742
1154
  render
743
1155
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/runtime",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "Runtime DOM renderer for Constela UI framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,7 +28,8 @@
28
28
  "jsdom": "^24.0.0",
29
29
  "tsup": "^8.0.0",
30
30
  "typescript": "^5.3.0",
31
- "vitest": "^2.0.0"
31
+ "vitest": "^2.0.0",
32
+ "@constela/server": "0.1.2"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=20.0.0"