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