Skip to main content

nargo_hydrate/
lib.rs

1#![warn(missing_docs)]
2
3use nargo_bundler::targets::js::{writer::JsWriter, JsBackend};
4use nargo_ir::{IRModule, TemplateNodeIR};
5use nargo_types::{CompileMode, Result};
6use std::collections::HashSet;
7
8pub struct HydrateBackend {
9    pub runtime_path: String,
10    pub mode: CompileMode,
11}
12
13impl HydrateBackend {
14    pub fn new(mode: CompileMode) -> Self {
15        let runtime_path = match mode {
16            CompileMode::Vue2 => "nargo".to_string(),
17            CompileMode::Vue => "vue".to_string(),
18        };
19        Self { runtime_path, mode }
20    }
21}
22
23impl Default for HydrateBackend {
24    fn default() -> Self {
25        Self::new(CompileMode::Vue2)
26    }
27}
28
29impl HydrateBackend {
30    pub fn generate(&self, ir: &IRModule) -> Result<String> {
31        let mut writer = JsWriter::new();
32        let mut used_core = HashSet::new();
33
34        // 1. Generate Hydrate Function Body
35        let mut body_writer = JsWriter::new();
36        Self::generate_hydrate_body(ir, &mut body_writer, &mut used_core)?;
37
38        // 2. Generate Imports
39        if !used_core.is_empty() {
40            let mut imports: Vec<_> = used_core.into_iter().collect();
41            imports.sort();
42            let import_source = if self.mode == CompileMode::Vue { "vue".to_string() } else { format!("{}/core", self.runtime_path) };
43            writer.write_line(&format!("import {{ {} }} from '{}';", imports.join(", "), import_source));
44            writer.newline();
45        }
46
47        // 3. Append Body
48        writer.append(body_writer);
49
50        Ok(writer.finish().0)
51    }
52
53    fn generate_hydrate_body(ir: &IRModule, writer: &mut JsWriter, used_core: &mut HashSet<String>) -> Result<()> {
54        writer.write_block("export function hydrate(root, ctx, options = {})", |writer| {
55            // 1. Initialize state from server if available
56            writer.write_line("if (window.__Nargo_STATE__) {");
57            writer.indent();
58            writer.write_line("Object.assign(ctx, window.__Nargo_STATE__);");
59            writer.dedent();
60            writer.write_line("}");
61            writer.newline();
62
63            // 2. Execute normal script and client script to set up reactivity
64            let mut used_dom = HashSet::new();
65            if let Some(script) = &ir.script {
66                for stmt in &script.body {
67                    JsWriter::generate_stmt(stmt, writer, ir, used_core, &mut used_dom, false);
68                }
69            }
70            if let Some(script_client) = &ir.script_client {
71                for stmt in &script_client.body {
72                    JsWriter::generate_stmt(stmt, writer, ir, used_core, &mut used_dom, false);
73                }
74            }
75            writer.newline();
76
77            // 3. Setup DOM nodes mapping
78            writer.write_line("const nodes = new Map();");
79            writer.write_line("const eventHandlers = new Map();");
80            writer.write_line("const selector = options.region ? `[data-nargo-region='${options.region}'] [data-nargo-id]` : '[data-nargo-id]';");
81            writer.write_line("const elements = root.querySelectorAll(selector);");
82            writer.write_line("for (let i = 0; i < elements.length; i++) {");
83            writer.indent();
84            writer.write_line("const el = elements[i];");
85            writer.write_line("const id = el.getAttribute('data-nargo-id');");
86            writer.write_line("if (id) nodes.set(id, el);");
87            writer.dedent();
88            writer.write_line("}");
89            writer.newline();
90
91            // 4. Setup event delegation
92            writer.write_line("root.addEventListener('click', (e) => {");
93            writer.indent();
94            writer.write_line("let target = e.target;");
95            writer.write_line("while (target && target !== root) {");
96            writer.indent();
97            writer.write_line("const id = target.getAttribute('data-nargo-id');");
98            writer.write_line("if (id) {");
99            writer.indent();
100            writer.write_line("const handlers = eventHandlers.get('click');");
101            writer.write_line("if (handlers && handlers[id]) {");
102            writer.indent();
103            writer.write_line("handlers[id](e);");
104            writer.dedent();
105            writer.write_line("}");
106            writer.dedent();
107            writer.write_line("}");
108            writer.write_line("target = target.parentElement;");
109            writer.dedent();
110            writer.write_line("}");
111            writer.dedent();
112            writer.write_line("});");
113            writer.newline();
114
115            // 4. Generate hydration logic for template
116            if let Some(template) = &ir.template {
117                let mut node_index = 0;
118                for node in &template.nodes {
119                    Self::generate_node_hydrate(node, writer, &mut node_index, used_core)?;
120                }
121                Ok(())
122            }
123            else {
124                Ok(())
125            }
126        });
127        writer.newline();
128
129        // Generate partial hydrate function
130        writer.write_block("export function partialHydrate(root, ctx, region)", |writer| {
131            writer.write_line("return hydrate(root, ctx, { region });");
132            Ok(())
133        });
134        Ok(())
135    }
136
137    fn generate_node_hydrate(node: &TemplateNodeIR, writer: &mut JsWriter, node_index: &mut usize, used_core: &mut HashSet<String>) -> Result<()> {
138        match node {
139            TemplateNodeIR::Element(el) => {
140                let current_index = *node_index;
141                *node_index += 1;
142
143                // Only generate hydration code if the element is dynamic
144                if !el.is_static {
145                    let el_var = format!("el{}", current_index);
146                    writer.write_line(&format!("const {} = nodes.get('{}');", el_var, current_index));
147
148                    // Handle event listeners (directives like on:click and @click)
149                    for attr in &el.attributes {
150                        if (attr.is_directive && attr.name == "on") || attr.name.starts_with('@') {
151                            let event_name = if attr.name == "on" {
152                                attr.argument.as_deref().unwrap_or("")
153                            }
154                            else {
155                                // Handle @click syntax
156                                attr.name.trim_start_matches('@')
157                            };
158                            if !event_name.is_empty() {
159                                if let Some(value) = &attr.value {
160                                    writer.write_line(&format!("if (!eventHandlers.has('{}')) eventHandlers.set('{}', {{}});", event_name, event_name));
161                                    writer.write_line(&format!("eventHandlers.get('{}')['{}'] = (e) => {};", event_name, current_index, value));
162                                }
163                            }
164                        }
165                        else if attr.is_dynamic || attr.name.starts_with(':') {
166                            // Handle dynamic attributes (e.g. bind:class, :class, bind:style)
167                            let attr_name = if attr.name == "bind" {
168                                attr.argument.as_deref().unwrap_or("")
169                            }
170                            else if attr.name.starts_with(':') {
171                                // Handle :class syntax
172                                attr.name.trim_start_matches(':')
173                            }
174                            else {
175                                &attr.name
176                            };
177                            if !attr_name.is_empty() {
178                                let value = attr.value.as_deref().unwrap_or("null");
179                                // Check if value is likely dynamic (contains variables or expressions)
180                                if value.contains('+') || value.contains('(') || value.contains('{') || value.contains('.') || value.contains('[') {
181                                    used_core.insert("createEffect".to_string());
182                                    let last_value_var = format!("last{}Val{}", attr_name, current_index);
183                                    writer.write_line(&format!("let {} = {};", last_value_var, value));
184                                    writer.write_block("createEffect(() =>", |writer| {
185                                        writer.write_line(&format!("const currentValue = {};", value));
186                                        writer.write_line(&format!("if (currentValue !== {}) {{  ", last_value_var));
187                                        writer.indent();
188                                        writer.write_line(&format!("{}.setAttribute('{}', currentValue);", el_var, attr_name));
189                                        writer.write_line(&format!("{} = currentValue;", last_value_var));
190                                        writer.dedent();
191                                        writer.write_line("}");
192                                        Ok(())
193                                    });
194                                    writer.write_line(");");
195                                }
196                                else {
197                                    // Static value, set once
198                                    writer.write_line(&format!("{}.setAttribute('{}', {});", el_var, attr_name, value));
199                                }
200                            }
201                        }
202                    }
203                }
204
205                // Always recurse into children to keep node_index in sync with SSR
206                for child in &el.children {
207                    Self::generate_node_hydrate(child, writer, node_index, used_core)?;
208                }
209                Ok(())
210            }
211            TemplateNodeIR::Interpolation(expr) => {
212                let current_index = *node_index;
213                *node_index += 1;
214
215                writer.write_line(&format!("const text{} = nodes.get('{}');", current_index, current_index));
216                // Check if expression is likely dynamic
217                if expr.code.contains('+') || expr.code.contains('(') || expr.code.contains('{') || expr.code.contains('.') || expr.code.contains('[') {
218                    used_core.insert("createEffect".to_string());
219                    writer.write_line(&format!("let lastText{} = {};", current_index, expr.code));
220                    writer.write_block("createEffect(() =>", |writer| {
221                        writer.write_line(&format!("const currentText = {};", expr.code));
222                        writer.write_line(&format!("if (currentText !== lastText{}) {{  ", current_index));
223                        writer.indent();
224                        writer.write_line(&format!("text{}.textContent = currentText;", current_index));
225                        writer.write_line(&format!("lastText{} = currentText;", current_index));
226                        writer.dedent();
227                        writer.write_line("}");
228                        Ok(())
229                    });
230                    writer.write_line(");");
231                }
232                else {
233                    // Static value, set once
234                    writer.write_line(&format!("text{}.textContent = {};", current_index, expr.code));
235                }
236                Ok(())
237            }
238            _ => {
239                // Static text, comments and hoisted nodes still occupy an index
240                *node_index += 1;
241                Ok(())
242            }
243        }
244    }
245}