Add model constant export to JS, rsxapp hydration, on_stop lifecycle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-10 09:08:20 +00:00
parent 611e269465
commit d047b49d39
58 changed files with 207 additions and 58 deletions

View File

@@ -127,21 +127,8 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Only regenerate if source is newer than stub
if ($stub_mtime >= $source_mtime) {
// Also check if the model metadata has changed
// by comparing a hash of enums, relationships, and columns
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
// by comparing a hash of enums, relationships, columns, and constants
$model_metadata = static::_get_model_metadata_for_hash($fqcn, $class_name, $manifest_data);
$model_metadata_hash = md5(json_encode($model_metadata));
$old_metadata_hash = $metadata['model_metadata_hash'] ?? '';
@@ -164,21 +151,7 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Store the metadata hash for future comparisons if not already done
if (!isset($manifest_data['data']['files'][$file_path]['model_metadata_hash'])) {
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
$model_metadata = static::_get_model_metadata_for_hash($fqcn, $class_name, $manifest_data);
$manifest_data['data']['files'][$file_path]['model_metadata_hash'] = md5(json_encode($model_metadata));
}
}
@@ -262,6 +235,46 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
return false;
}
/**
* Get model metadata for hash comparison (detects when stubs need regeneration)
*
* @param string $fqcn Fully qualified class name
* @param string $class_name Simple class name
* @param array $manifest_data The manifest data array
* @return array Metadata array for hashing
*/
private static function _get_model_metadata_for_hash(string $fqcn, string $class_name, array $manifest_data): array
{
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
// Get public constants defined directly on this class
$reflection = new \ReflectionClass($fqcn);
$constants = [];
foreach ($reflection->getReflectionConstants(\ReflectionClassConstant::IS_PUBLIC) as $const) {
if ($const->getDeclaringClass()->getName() === $fqcn) {
$constants[$const->getName()] = $const->getValue();
}
}
if (!empty($constants)) {
$model_metadata['constants'] = $constants;
}
return $model_metadata;
}
/**
* Sanitize model name for use as filename
*/
@@ -314,6 +327,32 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
$js_model_base_class = config('rsx.js_model_base_class');
$extends_class = $js_model_base_class ?: 'Rsx_Js_Model';
// Collect enum constant names to avoid duplicating them
$enum_constant_names = [];
foreach ($enums as $column => $enum_values) {
foreach ($enum_values as $value => $props) {
if (!empty($props['constant'])) {
$enum_constant_names[] = $props['constant'];
}
}
}
// Get all public constants defined directly on this model class (not inherited)
$reflection = new \ReflectionClass($fqcn);
$non_enum_constants = [];
foreach ($reflection->getReflectionConstants(\ReflectionClassConstant::IS_PUBLIC) as $const) {
// Only include constants defined directly on this class
if ($const->getDeclaringClass()->getName() !== $fqcn) {
continue;
}
$const_name = $const->getName();
// Skip constants already generated from enums
if (in_array($const_name, $enum_constant_names)) {
continue;
}
$non_enum_constants[$const_name] = $const->getValue();
}
// Start building the stub content
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
@@ -326,6 +365,16 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Add static __MODEL property for PHP model name resolution
$content .= " static __MODEL = '{$class_name}';\n\n";
// Generate non-enum constants first (static properties)
if (!empty($non_enum_constants)) {
$content .= " // Non-enum constants\n";
foreach ($non_enum_constants as $const_name => $const_value) {
$value_json = json_encode($const_value);
$content .= " static {$const_name} = {$value_json};\n";
}
$content .= "\n";
}
// Generate enum constants and methods
foreach ($enums as $column => $enum_values) {
// Sort enum values by order property first, then by key

View File

@@ -222,6 +222,24 @@ class Rsx {
return !window.rsxapp.debug;
}
/**
* Get the current logged-in user model instance
* Returns the hydrated ORM model if available, or the raw data object
* @returns {Rsx_Js_Model|Object|null} User model instance or null if not logged in
*/
static user() {
return window.rsxapp?.user || null;
}
/**
* Get the current site model instance
* Returns the hydrated ORM model if available, or the raw data object
* @returns {Rsx_Js_Model|Object|null} Site model instance or null if not set
*/
static site() {
return window.rsxapp?.site || null;
}
// Generates a unique number for the application instance
static uid() {
if (typeof Rsx._uid == undef) {
@@ -601,6 +619,43 @@ class Rsx {
}
}
/**
* Hydrate rsxapp.user and rsxapp.site into ORM model instances
*
* Checks if window.rsxapp.user and window.rsxapp.site contain raw data objects
* with __MODEL markers, and if the corresponding model classes are available,
* replaces them with proper ORM instances.
*
* This enables code like:
* const user = Rsx.user();
* await user.some_relationship(); // Works because user is a proper model instance
*/
static _hydrate_rsxapp_models() {
if (!window.rsxapp) {
return;
}
// Hydrate user if present and has __MODEL marker
if (window.rsxapp.user && window.rsxapp.user.__MODEL) {
const UserClass = Manifest.get_class_by_name(window.rsxapp.user.__MODEL);
// Check class exists and extends Rsx_Js_Model - @JS-DEFENSIVE-01-EXCEPTION - dynamic model resolution
if (UserClass && Manifest.js_is_subclass_of(UserClass, Rsx_Js_Model)) {
window.rsxapp.user = new UserClass(window.rsxapp.user);
console_debug('RSX_INIT', `Hydrated rsxapp.user as ${window.rsxapp.user.__MODEL}`);
}
}
// Hydrate site if present and has __MODEL marker
if (window.rsxapp.site && window.rsxapp.site.__MODEL) {
const SiteClass = Manifest.get_class_by_name(window.rsxapp.site.__MODEL);
// Check class exists and extends Rsx_Js_Model - @JS-DEFENSIVE-01-EXCEPTION - dynamic model resolution
if (SiteClass && Manifest.js_is_subclass_of(SiteClass, Rsx_Js_Model)) {
window.rsxapp.site = new SiteClass(window.rsxapp.site);
console_debug('RSX_INIT', `Hydrated rsxapp.site as ${window.rsxapp.site.__MODEL}`);
}
}
}
/**
* Internal: Execute multi-phase initialization for all registered classes
* This runs various initialization phases in order to properly set up the application
@@ -617,6 +672,10 @@ class Rsx {
// Setup exception handlers first, before any initialization phases
Rsx._setup_exception_handlers();
// Hydrate rsxapp.user and rsxapp.site into ORM model instances
// This must happen early, before any code tries to use these objects
Rsx._hydrate_rsxapp_models();
// Get all registered classes from the manifest
const all_classes = Manifest.get_all_classes();
@@ -697,7 +756,6 @@ class Rsx {
y: window.scrollY
};
sessionStorage.setItem(Rsx._SCROLL_STORAGE_KEY, JSON.stringify(scroll_data));
console.log('[Rsx Scroll] Saved:', scroll_data.x, scroll_data.y, 'for', scroll_data.url);
}, 100); // 100ms debounce
}
@@ -707,61 +765,46 @@ class Rsx {
* @private
*/
static _restore_scroll_on_refresh() {
console.log('[Rsx Scroll] _restore_scroll_on_refresh called');
// Set up scroll listener to continuously save position
window.addEventListener('scroll', Rsx._save_scroll_position, { passive: true });
console.log('[Rsx Scroll] Scroll listener attached');
// Check if this is a page refresh using Performance API
const nav_entries = performance.getEntriesByType('navigation');
console.log('[Rsx Scroll] Navigation entries:', nav_entries.length);
if (nav_entries.length === 0) {
console.log('[Rsx Scroll] No navigation entries found, skipping restore');
return;
}
const nav_type = nav_entries[0].type;
console.log('[Rsx Scroll] Navigation type:', nav_type);
if (nav_type !== 'reload') {
console.log('[Rsx Scroll] Not a reload (type=' + nav_type + '), skipping restore');
return;
}
// This is a refresh - try to restore scroll position
const stored = sessionStorage.getItem(Rsx._SCROLL_STORAGE_KEY);
console.log('[Rsx Scroll] Stored scroll data:', stored);
if (!stored) {
console.log('[Rsx Scroll] No stored scroll position found');
return;
}
try {
const scroll_data = JSON.parse(stored);
const current_url = window.location.pathname + window.location.search;
console.log('[Rsx Scroll] Stored URL:', scroll_data.url, 'Current URL:', current_url);
// Only restore if URL matches
if (scroll_data.url !== current_url) {
console.log('[Rsx Scroll] URL mismatch, skipping restore');
return;
}
// Restore scroll position instantly
console.log('[Rsx Scroll] Restoring scroll to:', scroll_data.x, scroll_data.y);
window.scrollTo({
left: scroll_data.x,
top: scroll_data.y,
behavior: 'instant'
});
console.log('[Rsx Scroll] Restored scroll position on refresh:', scroll_data.x, scroll_data.y);
// Clear stored position after successful restore
sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY);
} catch (e) {
// Invalid JSON or other error - ignore
console.log('[Rsx Scroll] Error restoring scroll:', e.message);
sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY);
}
}

View File

@@ -658,6 +658,63 @@ ENUM PROPERTIES
const statusConfig = Project_Model.status_id_enum_val();
// {1: {label: "Planning", badge: "bg-info"}, 2: {...}, ...}
MODEL CONSTANTS
All public constants defined on a PHP model are automatically exported to
the JavaScript stub as static properties. This includes both enum constants
(generated from $enums) and manually defined constants.
PHP Model Definition:
class User_Model extends Rsx_Model_Abstract
{
// Enum constants (auto-generated by rsx:migrate:document_models)
const ROLE_ADMIN = 1;
const ROLE_USER = 2;
// Permission constants (manually defined)
const PERM_MANAGE_USERS = 1;
const PERM_EDIT_DATA = 2;
const PERM_VIEW_DATA = 3;
// Invitation status constants
const INVITATION_PENDING = 'pending';
const INVITATION_ACCEPTED = 'accepted';
const INVITATION_EXPIRED = 'expired';
}
JavaScript Usage:
// All constants available as static properties
if (permission === User_Model.PERM_MANAGE_USERS) {
showAdminPanel();
}
if (invite.status === User_Model.INVITATION_PENDING) {
showPendingBadge();
}
SECURITY WARNING:
All public constants are exported to JavaScript and visible in the
browser. NEVER put sensitive values in model constants:
// NEVER DO THIS - secrets visible in browser
const API_SECRET = 'sk-abc123...';
const ENCRYPTION_KEY = 'my-secret-key';
Use private constants for sensitive values:
// Safe - not exported to JavaScript
private const API_SECRET = 'sk-abc123...';
Constant Types Exported:
- Enum constants (from $enums 'constant' field)
- Non-enum public constants (string, int, float, bool, array)
- Constants with no visibility modifier (treated as public)
Constants NOT Exported:
- Private constants (private const FOO = 1)
- Protected constants (protected const BAR = 2)
- Constants inherited from parent classes
- Framework constants from Rsx_Model_Abstract
===============================================================================
FUTURE DEVELOPMENT
===============================================================================

View File

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/config.js Executable file → Normal file
View File

0
app/RSpade/resource/vscode_extension/out/config.js.map Executable file → Normal file
View File

View File

View File

View File

View File

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/extension.js Executable file → Normal file
View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -29,7 +29,7 @@ const ide_bridge_client_1 = require("./ide_bridge_client");
/**
* JQHTML lifecycle methods that are called automatically by the framework
*/
const JQHTML_LIFECYCLE_METHODS = ['on_render', 'on_create', 'on_load', 'on_ready', 'on_destroy', 'cache_id'];
const JQHTML_LIFECYCLE_METHODS = ['on_render', 'on_create', 'on_load', 'on_ready', 'on_stop', 'cache_id'];
/**
* Convention methods that are called automatically by the RSX framework
*/
@@ -52,7 +52,7 @@ const LIFECYCLE_DOCS = {
on_create: 'Quick setup after DOM exists. Children complete before parent. DOM manipulation allowed. MUST be synchronous.',
on_load: 'Data loading phase - fetch async data. NO DOM manipulation allowed, only update `this.data`. Template re-renders after load. MUST be async.',
on_ready: 'Final setup phase - all data loaded. Children complete before parent. DOM manipulation allowed. Can be sync or async.',
on_destroy: 'Component destruction phase - cleanup resources. Called before component is removed. MUST be synchronous.',
on_stop: 'Component destruction phase - cleanup resources. Called before component is removed. MUST be synchronous.',
cache_id: 'Returns a unique cache key for this component instance. Used by framework to cache/restore component state. Return null to disable caching.',
};
/**
@@ -280,7 +280,7 @@ class JqhtmlLifecycleHoverProvider {
markdown.isTrusted = true;
const is_async = is_async_method(line);
// Determine if async is required, forbidden, or optional
const must_be_sync = ['on_create', 'on_render', 'on_destroy'].includes(word);
const must_be_sync = ['on_create', 'on_render', 'on_stop'].includes(word);
const must_be_async = word === 'on_load';
const can_be_either = word === 'on_ready';
let has_error = false;
@@ -377,8 +377,8 @@ class JqhtmlLifecycleDiagnosticProvider {
else if (method_name === 'on_render' && is_async) {
diagnostics.push(new vscode.Diagnostic(method_name_range, `'on_render' must be synchronous - remove 'async' keyword`, vscode.DiagnosticSeverity.Error));
}
else if (method_name === 'on_destroy' && is_async) {
diagnostics.push(new vscode.Diagnostic(method_name_range, `'on_destroy' must be synchronous - remove 'async' keyword`, vscode.DiagnosticSeverity.Error));
else if (method_name === 'on_stop' && is_async) {
diagnostics.push(new vscode.Diagnostic(method_name_range, `'on_stop' must be synchronous - remove 'async' keyword`, vscode.DiagnosticSeverity.Error));
}
else if (method_name === 'on_load' && !is_async) {
diagnostics.push(new vscode.Diagnostic(method_name_range, `'on_load' must be async - add 'async' keyword`, vscode.DiagnosticSeverity.Error));

File diff suppressed because one or more lines are too long

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -2,7 +2,7 @@
"name": "rspade-framework",
"displayName": "RSpade Framework Support",
"description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management",
"version": "0.1.219",
"version": "0.1.220",
"publisher": "rspade",
"engines": {
"vscode": "^1.74.0"

View File

@@ -4,7 +4,7 @@ import { IdeBridgeClient } from './ide_bridge_client';
/**
* JQHTML lifecycle methods that are called automatically by the framework
*/
const JQHTML_LIFECYCLE_METHODS = ['on_render', 'on_create', 'on_load', 'on_ready', 'on_destroy', 'cache_id'];
const JQHTML_LIFECYCLE_METHODS = ['on_render', 'on_create', 'on_load', 'on_ready', 'on_stop', 'cache_id'];
/**
* Convention methods that are called automatically by the RSX framework
@@ -29,7 +29,7 @@ const LIFECYCLE_DOCS: { [key: string]: string } = {
on_create: 'Quick setup after DOM exists. Children complete before parent. DOM manipulation allowed. MUST be synchronous.',
on_load: 'Data loading phase - fetch async data. NO DOM manipulation allowed, only update `this.data`. Template re-renders after load. MUST be async.',
on_ready: 'Final setup phase - all data loaded. Children complete before parent. DOM manipulation allowed. Can be sync or async.',
on_destroy: 'Component destruction phase - cleanup resources. Called before component is removed. MUST be synchronous.',
on_stop: 'Component destruction phase - cleanup resources. Called before component is removed. MUST be synchronous.',
cache_id: 'Returns a unique cache key for this component instance. Used by framework to cache/restore component state. Return null to disable caching.',
};
@@ -299,7 +299,7 @@ export class JqhtmlLifecycleHoverProvider implements vscode.HoverProvider {
const is_async = is_async_method(line);
// Determine if async is required, forbidden, or optional
const must_be_sync = ['on_create', 'on_render', 'on_destroy'].includes(word);
const must_be_sync = ['on_create', 'on_render', 'on_stop'].includes(word);
const must_be_async = word === 'on_load';
const can_be_either = word === 'on_ready';
@@ -440,11 +440,11 @@ export class JqhtmlLifecycleDiagnosticProvider {
vscode.DiagnosticSeverity.Error
)
);
} else if (method_name === 'on_destroy' && is_async) {
} else if (method_name === 'on_stop' && is_async) {
diagnostics.push(
new vscode.Diagnostic(
method_name_range,
`'on_destroy' must be synchronous - remove 'async' keyword`,
`'on_stop' must be synchronous - remove 'async' keyword`,
vscode.DiagnosticSeverity.Error
)
);