Improve Jqhtml_Integration.js documentation with hydration system explanation Add jqhtml-laravel integration packages for traditional Laravel projects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
16 KiB
JavaScript
Executable File
300 lines
16 KiB
JavaScript
Executable File
/**
|
|
* JQHTML Integration - Component Hydration System
|
|
*
|
|
* This module bridges server-rendered HTML and client-side jqhtml components.
|
|
*
|
|
* == THE HYDRATION PROBLEM ==
|
|
*
|
|
* When PHP/Blade renders a page, jqhtml components appear as placeholder elements:
|
|
*
|
|
* Blade source: <User_Card $name="John" />
|
|
* Rendered HTML: <div class="Component_Init"
|
|
* data-component-init-name="User_Card"
|
|
* data-component-args='{"name":"John"}'>
|
|
* </div>
|
|
*
|
|
* These are just div tags - they have no behavior until JavaScript runs.
|
|
* "Hydration" is the process of finding these placeholders and converting them
|
|
* into live, interactive jqhtml components.
|
|
*
|
|
* == TWO-PHASE INITIALIZATION ==
|
|
*
|
|
* Phase 1: _on_framework_modules_define() - Component Registration
|
|
* - Runs early in framework boot, before DOM is processed
|
|
* - Registers all ES6 classes extending Component with jqhtml runtime
|
|
* - After this phase, jqhtml knows: "User_Card" → UserCardClass
|
|
*
|
|
* Phase 2: _on_framework_modules_init() - DOM Hydration
|
|
* - Runs after templates are loaded
|
|
* - Finds all .Component_Init elements in the DOM
|
|
* - Extracts component name and args from data attributes
|
|
* - Calls $element.component(name, args) to hydrate each one
|
|
* - Recursively processes nested components
|
|
* - Triggers 'jqhtml_ready' when all components are initialized
|
|
*
|
|
* == THE TRANSFORMATION ==
|
|
*
|
|
* Before hydration:
|
|
* <div class="Component_Init" data-component-init-name="User_Card" ...>
|
|
*
|
|
* After hydration:
|
|
* <div class="User_Card"> ← Component_Init removed, component class added
|
|
* [component template content] ← Template rendered into element
|
|
* </div>
|
|
*
|
|
* The element is now a live component with event handlers, state, and lifecycle.
|
|
*
|
|
* == KEY PARTICIPANTS ==
|
|
*
|
|
* JqhtmlBladeCompiler.php - Transforms <Component /> tags into .Component_Init divs
|
|
* jqhtml runtime - Maintains registry of component names → classes
|
|
* This module - Orchestrates registration and hydration
|
|
* $().component() - jQuery plugin that creates component instances
|
|
*/
|
|
class Jqhtml_Integration {
|
|
/**
|
|
* Phase 1: Register Component Classes
|
|
*
|
|
* Compiled .jqhtml templates self-register their render methods with jqhtml.
|
|
* But the framework must separately register ES6 component classes (the ones
|
|
* extending Component with lifecycle methods like on_create, on_load, etc).
|
|
*
|
|
* This runs during framework_modules_define, before any DOM processing.
|
|
*/
|
|
static _on_framework_modules_define() {
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Register Component Classes with jqhtml Runtime
|
|
//
|
|
// The Manifest knows all classes extending Component. We register each
|
|
// with jqhtml so it can instantiate them by name during hydration.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
let jqhtml_components = Manifest.get_extending('Component');
|
|
console_debug('JQHTML_INIT', 'Registering ' + jqhtml_components.length + ' Components');
|
|
|
|
for (let component of jqhtml_components) {
|
|
jqhtml.register_component(component.class_name, component.class_object);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Tag Static Methods with Cache IDs
|
|
//
|
|
// jqhtml caches component output based on args. When a function is passed
|
|
// as an arg (e.g., DataGrid's data_source), we need a stable string key.
|
|
//
|
|
// Without this: data_source: function() {...} → no cache (functions aren't serializable)
|
|
// With this: function._jqhtml_cache_id = "My_DataGrid.fetch_data" → cacheable
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
const all_classes = Manifest.get_all_classes();
|
|
let methods_tagged = 0;
|
|
|
|
for (const class_info of all_classes) {
|
|
const class_object = class_info.class_object;
|
|
const class_name = class_info.class_name;
|
|
const property_names = Object.getOwnPropertyNames(class_object);
|
|
|
|
for (const property_name of property_names) {
|
|
if (property_name === 'length' || property_name === 'name' || property_name === 'prototype') {
|
|
continue;
|
|
}
|
|
|
|
const property_value = class_object[property_name];
|
|
if (typeof property_value === 'function') {
|
|
property_value._jqhtml_cache_id = `${class_name}.${property_name}`;
|
|
methods_tagged++;
|
|
}
|
|
}
|
|
}
|
|
|
|
console_debug('JQHTML_INIT', `Tagged ${methods_tagged} static methods with _jqhtml_cache_id`);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Configure jqhtml Caching
|
|
//
|
|
// scope_key() changes when: app code changes, user logs out, site changes.
|
|
// This automatically invalidates cached component renders.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
jqhtml.set_cache_key(Rsx.scope_key());
|
|
window.jqhtml.debug.verbose = false;
|
|
}
|
|
|
|
/**
|
|
* Phase 2: DOM Hydration
|
|
*
|
|
* Finds all .Component_Init placeholders and converts them into live components.
|
|
*
|
|
* == HYDRATION PROCESS ==
|
|
*
|
|
* For each .Component_Init element:
|
|
*
|
|
* 1. EXTRACT: Read data-component-init-name and data-component-args
|
|
* 2. CLEANUP: Remove data attributes (prevents re-hydration, hides implementation)
|
|
* 3. CAPTURE: Save innerHTML for slot/content() processing
|
|
* 4. INSTANTIATE: Call $element.component(name, args)
|
|
* 5. RECURSE: After render, hydrate any nested .Component_Init elements
|
|
*
|
|
* == NESTED COMPONENT HANDLING ==
|
|
*
|
|
* Components can contain other components in their Blade output:
|
|
*
|
|
* <User_Card> ← Parent hydrates first
|
|
* <Avatar /> ← Child hydrates after parent renders
|
|
* </User_Card>
|
|
*
|
|
* We skip nested .Component_Init elements on first pass (they're inside a parent
|
|
* that hasn't rendered yet). After each component renders, we recursively scan
|
|
* its content for children to hydrate.
|
|
*
|
|
* == ASYNC COMPLETION ==
|
|
*
|
|
* Hydration is async because components may have on_load() methods that fetch data.
|
|
* We track all component promises and trigger 'jqhtml_ready' only when every
|
|
* component (including nested ones) has completed its full lifecycle.
|
|
*
|
|
* @param {jQuery} [$scope] Scope to search (defaults to body). Recursive calls pass component.$
|
|
* @returns {Array<Promise>|undefined} Promises for recursive calls; undefined for top-level
|
|
*/
|
|
static _on_framework_modules_init($scope) {
|
|
const is_top_level = !$scope;
|
|
const promises = [];
|
|
const components_needing_init = ($scope || $('body')).find('.Component_Init');
|
|
|
|
if (components_needing_init.length > 0) {
|
|
console_debug('JQHTML_INIT', `Initializing ${components_needing_init.length} DOM components`);
|
|
}
|
|
|
|
components_needing_init.each(function () {
|
|
const $element = $(this);
|
|
|
|
// Guard: Element may have been removed by a parent component's render
|
|
if (!document.contains($element[0])) {
|
|
return;
|
|
}
|
|
|
|
// Guard: Skip nested components - they'll be processed after their parent renders
|
|
let parent = $element[0].parentElement;
|
|
while (parent) {
|
|
if (parent.classList.contains('Component_Init')) {
|
|
return;
|
|
}
|
|
parent = parent.parentElement;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// STEP 1: Extract hydration data from placeholder element
|
|
// ─────────────────────────────────────────────────────────────────
|
|
const component_name = $element.attr('data-component-init-name');
|
|
const args_string = $element.attr('data-component-args');
|
|
|
|
let component_args = {};
|
|
if (args_string) {
|
|
try {
|
|
component_args = JSON.parse(args_string);
|
|
} catch (e) {
|
|
console.error(`[JQHTML Integration] Failed to parse component args for ${component_name}:`, e);
|
|
component_args = {};
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// STEP 2: Remove hydration markers (cleanup)
|
|
//
|
|
// These attributes served their purpose. Removing them:
|
|
// - Prevents accidental re-hydration
|
|
// - Hides implementation details from DOM inspection
|
|
// - Prevents other code from depending on this internal mechanism
|
|
// ─────────────────────────────────────────────────────────────────
|
|
$element.removeAttr('data-component-init-name');
|
|
$element.removeAttr('data-component-args');
|
|
$element.removeData('component-init-name');
|
|
$element.removeData('component-args');
|
|
|
|
if (component_name) {
|
|
let component_args_filtered = {};
|
|
for (const [key, value] of Object.entries(component_args)) {
|
|
if (key.startsWith('data-')) {
|
|
component_args_filtered[key.substring(5)] = value;
|
|
} else {
|
|
component_args_filtered[key] = value;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// ─────────────────────────────────────────────────────────
|
|
// STEP 3: Capture innerHTML for slot/content() processing
|
|
//
|
|
// Blade content between <Component>...</Component> tags
|
|
// becomes available to the template via content() function
|
|
// ─────────────────────────────────────────────────────────
|
|
component_args_filtered._inner_html = $element.html();
|
|
$element.empty();
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// STEP 4: Instantiate the component
|
|
//
|
|
// Remove .Component_Init first to prevent re-initialization
|
|
// if this element is somehow scanned again
|
|
// ─────────────────────────────────────────────────────────
|
|
$element.removeClass('Component_Init');
|
|
|
|
const component_promise = new Promise((resolve) => {
|
|
// $().component(name, args) creates the component instance,
|
|
// binds it to the element, and starts the lifecycle
|
|
let component = $element.component(component_name, component_args_filtered).component();
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// STEP 5: Recurse after render
|
|
//
|
|
// Component's template may contain nested .Component_Init
|
|
// elements. We must wait for render before we can find them.
|
|
//
|
|
// Note: If the template changed the tag (e.g., tag="button"),
|
|
// the original $element may have been replaced. Always use
|
|
// component.$ to get the current element.
|
|
// ─────────────────────────────────────────────────────
|
|
component.on('render', function () {
|
|
const nested_promises = Jqhtml_Integration._on_framework_modules_init(component.$);
|
|
promises.push(...nested_promises);
|
|
resolve();
|
|
}).$;
|
|
});
|
|
|
|
promises.push(component_promise);
|
|
} catch (error) {
|
|
console.error(`[JQHTML Integration] Failed to initialize component ${component_name}:`, error);
|
|
console.error('Error details:', error.stack || error);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// COMPLETION: Top-level call waits for all components, then signals ready
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
if (is_top_level) {
|
|
(async () => {
|
|
await Promise.all(promises);
|
|
Rsx.trigger('jqhtml_ready');
|
|
})();
|
|
return;
|
|
}
|
|
|
|
// Recursive calls return promises for parent to collect
|
|
return promises;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Utility Methods (pass-through to jqhtml runtime)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/** Get all registered component names. Useful for debugging/introspection. */
|
|
static get_component_names() {
|
|
return jqhtml.get_component_names();
|
|
}
|
|
|
|
/** Check if a component is registered by name. */
|
|
static has_component(name) {
|
|
return jqhtml.has_component(name);
|
|
}
|
|
}
|
|
|
|
// Class is automatically made global by RSX manifest - no window assignment needed
|