Add progressive breadcrumb resolution with caching

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-12 08:55:34 +00:00
parent 2f96bb6276
commit 29b1abc0a1
5 changed files with 452 additions and 80 deletions

View File

@@ -0,0 +1,291 @@
/**
* Rsx_Breadcrumb_Resolver - Progressive breadcrumb resolution with caching
*
* Provides instant breadcrumb rendering using cached data while validating
* against live action data in the background.
*
* Resolution Phases:
* 1. Immediate Cached Render - If ALL chain links cached, render instantly
* 2. Active Action Resolution - Await live action's breadcrumb methods
* 3. Chain Walk - Walk parents using cache or detached action loads
* 4. Render Chain - Emit chain structure (may have null labels)
* 5. Progressive Updates - Update labels as they resolve
*
* Usage:
* const cancel = Rsx_Breadcrumb_Resolver.stream(action, url, (data) => {
* // data.title - Page title (null if pending)
* // data.chain - Array of {url, label, is_active, resolved}
* // data.fully_resolved - True when all labels resolved
* });
*
* // Call cancel() to stop streaming (e.g., on navigation)
*/
class Rsx_Breadcrumb_Resolver {
/**
* In-memory cache: url -> { label, label_active, parent_url }
* Cleared on page reload. Invalidated by visiting pages.
*/
static _cache = {};
/**
* Generation counter for cancellation detection.
* Incremented on each stream() call and when cancelled.
*/
static _generation = 0;
/**
* Stream breadcrumb data as it becomes available
*
* @param {Spa_Action} action - The active action instance
* @param {string} url - Current URL (pathname + search, no hash)
* @param {Function} callback - Called with breadcrumb data on each update
* @returns {Function} Cancel function - call to stop streaming
*/
static stream(action, url, callback) {
const generation = ++Rsx_Breadcrumb_Resolver._generation;
// Normalize URL (strip hash if present)
const normalized_url = Rsx_Breadcrumb_Resolver._normalize_url(url);
// Start async resolution (fires callbacks as data becomes available)
Rsx_Breadcrumb_Resolver._resolve(action, normalized_url, generation, callback);
// Return cancel function
return () => {
// Only increment if this is still the active generation
if (generation === Rsx_Breadcrumb_Resolver._generation) {
Rsx_Breadcrumb_Resolver._generation++;
}
};
}
/**
* Normalize URL by stripping hash
*/
static _normalize_url(url) {
const hash_index = url.indexOf('#');
return hash_index >= 0 ? url.substring(0, hash_index) : url;
}
/**
* Check if resolution should continue (not cancelled)
*/
static _is_active(generation) {
return generation === Rsx_Breadcrumb_Resolver._generation;
}
/**
* Main resolution orchestrator
*/
static async _resolve(action, url, generation, callback) {
// Phase 1: Try immediate cached render
const cached_chain = Rsx_Breadcrumb_Resolver._try_build_cached_chain(url);
if (cached_chain) {
// Full cache hit - render immediately
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callback({
title: null, // Title will be resolved in Phase 2
chain: cached_chain,
fully_resolved: false
});
}
// Phase 2: Await active action resolution
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const active_data = await Rsx_Breadcrumb_Resolver._resolve_active_action(action, url);
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
// Check if parent changed from cached value (stale cache)
const cached_entry = Rsx_Breadcrumb_Resolver._cache[url];
const parent_changed = cached_chain && cached_entry &&
cached_entry.parent_url !== active_data.parent_url;
// Phase 3-5: Walk chain and render progressively
await Rsx_Breadcrumb_Resolver._walk_and_render_chain(
active_data,
url,
generation,
callback,
parent_changed || !cached_chain // Force re-render if parent changed or no cached render
);
}
/**
* Phase 1: Try to build a complete chain from cache
* Returns null if any link is missing from cache
*/
static _try_build_cached_chain(url) {
const chain = [];
let current_url = url;
while (current_url) {
const entry = Rsx_Breadcrumb_Resolver._cache[current_url];
if (!entry) {
// Cache miss - can't build complete chain
return null;
}
chain.unshift({
url: current_url,
label: current_url === url ? entry.label_active : entry.label,
is_active: current_url === url,
resolved: true
});
current_url = entry.parent_url;
}
return chain;
}
/**
* Phase 2: Resolve all breadcrumb data from the live active action
*/
static async _resolve_active_action(action, url) {
// Await all breadcrumb methods in parallel
const [title, label, label_active, parent_url] = await Promise.all([
action.page_title(),
action.breadcrumb_label(),
action.breadcrumb_label_active(),
action.breadcrumb_parent()
]);
// Update cache with fresh values
Rsx_Breadcrumb_Resolver._cache[url] = {
label: label,
label_active: label_active || label,
parent_url: parent_url || null
};
return {
title: title,
label: label,
label_active: label_active || label,
parent_url: parent_url || null
};
}
/**
* Phase 3-5: Walk parent chain and render progressively
*/
static async _walk_and_render_chain(active_data, url, generation, callback, force_render) {
// Start chain with active action
const chain = [{
url: url,
label: active_data.label_active,
is_active: true,
resolved: true
}];
// Track pending async operations for progressive updates
let pending_count = 0;
let chain_complete = false;
// Helper to emit current state
const emit = () => {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callback({
title: active_data.title,
chain: [...chain], // Clone to prevent mutation issues
fully_resolved: chain_complete && pending_count === 0
});
};
// Walk up parent chain
let parent_url = active_data.parent_url;
while (parent_url) {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const cached = Rsx_Breadcrumb_Resolver._cache[parent_url];
if (cached) {
// Cache hit - use cached values immediately
chain.unshift({
url: parent_url,
label: cached.label,
is_active: false,
resolved: true
});
parent_url = cached.parent_url;
} else {
// Cache miss - need to load detached action
// Add placeholder entry
const entry_url = parent_url;
chain.unshift({
url: entry_url,
label: null, // Placeholder - will be resolved
is_active: false,
resolved: false
});
pending_count++;
// Load the detached action synchronously for chain structure
const detached = await Spa.load_detached_action(entry_url, { use_cached_data: true });
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) {
if (detached) detached.stop();
return;
}
if (detached) {
// Get parent_url to continue chain walk
const next_parent = await detached.breadcrumb_parent();
// Get label while we have the action
const label = await detached.breadcrumb_label();
const label_active = await detached.breadcrumb_label_active();
// Update cache
Rsx_Breadcrumb_Resolver._cache[entry_url] = {
label: label,
label_active: label_active || label,
parent_url: next_parent || null
};
// Update chain entry with resolved label
const found_entry = chain.find(e => e.url === entry_url);
if (found_entry) {
found_entry.label = label;
found_entry.resolved = true;
pending_count--;
}
detached.stop();
parent_url = next_parent;
} else {
// Failed to load - stop chain here
parent_url = null;
}
}
}
// Chain structure complete
chain_complete = true;
// Emit final state (or intermediate if still pending)
if (force_render || pending_count === 0) {
emit();
}
}
/**
* Clear the cache (called on window reload automatically)
* Can be called manually if needed
*/
static clear_cache() {
Rsx_Breadcrumb_Resolver._cache = {};
}
/**
* Get cache entry for debugging/inspection
*/
static get_cached(url) {
return Rsx_Breadcrumb_Resolver._cache[url] || null;
}
}

View File

@@ -28,6 +28,7 @@ class Core_Bundle extends Rsx_Bundle_Abstract
'app/RSpade/Core/Models', // Framework models (User_Model, Site_Model, etc.) 'app/RSpade/Core/Models', // Framework models (User_Model, Site_Model, etc.)
'app/RSpade/Core/SPA', 'app/RSpade/Core/SPA',
'app/RSpade/Core/Debug', // Debug components (JS_Tree_Debug_*) 'app/RSpade/Core/Debug', // Debug components (JS_Tree_Debug_*)
'app/RSpade/Breadcrumbs', // Progressive breadcrumb resolution
'app/RSpade/Lib', 'app/RSpade/Lib',
], ],
]; ];

View File

@@ -787,9 +787,19 @@ class Spa {
// Check if current container has a component with target class name // Check if current container has a component with target class name
// jqhtml adds class names to component root elements automatically // jqhtml adds class names to component root elements automatically
const $existing = $current_container.children().first(); //
// Special case for first iteration (i=0):
// The top-level layout is rendered ON #spa-root itself, not as a child.
// $.component() converts the container element into the component.
// So we check if the container itself has the target class.
//
// For subsequent iterations:
// Sublayouts and actions are rendered into the parent's $content area.
// The $content element becomes the component (same pattern).
// So we still check if the container itself has the target class.
const $existing = $current_container;
if ($existing.length && $existing.hasClass(target_name)) { if ($existing.hasClass(target_name)) {
// Match found - can potentially reuse this level // Match found - can potentially reuse this level
const existing_component = $existing.component(); const existing_component = $existing.component();
@@ -848,19 +858,19 @@ class Spa {
// Create component // Create component
const component = $current_container.component(component_name, is_last ? args : {}).component(); const component = $current_container.component(component_name, is_last ? args : {}).component();
// Wait for render to complete (not full ready - we don't need child data to load) if (i === 0) {
// This allows layout navigation to update immediately while action loads // Top-level layout - set reference immediately
await component.rendered(); Spa.layout = component;
}
if (is_last) { if (is_last) {
// This is the action // This is the action - set reference but don't wait
Spa.action = component; Spa.action = component;
} else { } else {
// This is a layout // This is a layout
if (i === 0) { // Wait for render to complete (not full ready - we don't need child data to load)
// Top-level layout // This allows layout navigation to update immediately while action loads
Spa.layout = component; await component.rendered();
}
// Move container to this layout's $content for next iteration // Move container to this layout's $content for next iteration
$current_container = component.$sid('content'); $current_container = component.$sid('content');

View File

@@ -1,9 +1,21 @@
BREADCRUMBS(3) RSX Framework Manual BREADCRUMBS(3) BREADCRUMBS(3) RSX Framework Manual BREADCRUMBS(3)
NAME NAME
breadcrumbs - Layout-managed breadcrumb navigation system for SPA actions breadcrumbs - Progressive breadcrumb navigation with caching
SYNOPSIS SYNOPSIS
Layout Integration:
class My_Spa_Layout extends Spa_Layout {
on_action(url, action_name, args) {
if (this._breadcrumb_cancel) this._breadcrumb_cancel();
this._breadcrumb_cancel = Rsx_Breadcrumb_Resolver.stream(
this.action, url,
(data) => this._render_breadcrumbs(data)
);
}
}
Action Methods: Action Methods:
class Contacts_View_Action extends Spa_Action { class Contacts_View_Action extends Spa_Action {
@@ -15,28 +27,51 @@ SYNOPSIS
} }
} }
Template Component:
<Breadcrumb_Nav $crumbs="<%= JSON.stringify(chain) %>" />
DESCRIPTION DESCRIPTION
The RSX breadcrumb system provides hierarchical navigation through action The RSX breadcrumb system provides progressive breadcrumb resolution with
methods that define breadcrumb metadata. Unlike traditional breadcrumb caching for instant SPA navigation. Breadcrumbs render immediately using
systems where each page hardcodes its full breadcrumb path, RSX uses cached data while validating against live action data in the background.
parent URL traversal to automatically build the breadcrumb chain.
Key characteristics: Key characteristics:
- Actions define breadcrumb behavior through async methods - Instant render from cache when all chain links are cached
- Layout walks up the parent chain using Spa.load_detached_action() - Progressive label updates for uncached pages (shows "------" placeholders)
- Each parent contributes its breadcrumb_label() to the chain - Active page always validates against live action data
- Active page uses breadcrumb_label_active() for descriptive text - In-memory cache persists for session (cleared on page reload)
- Document title set from page_title() method
This approach provides: Resolution Phases:
- Dynamic breadcrumb content based on loaded data 1. Immediate Cached Render - If ALL chain links cached, render instantly
- Automatic chain building without hardcoding paths 2. Active Action Resolution - Await live action's breadcrumb methods
- Consistent breadcrumb behavior across all SPA actions 3. Chain Walk - Walk parents using cache or detached action loads
- Descriptive active breadcrumb text for root pages 4. Render Chain - Emit chain structure (may have null labels)
5. Progressive Updates - Update labels as they resolve
CORE API
Rsx_Breadcrumb_Resolver.stream(action, url, callback)
Streams breadcrumb data as it becomes available.
Parameters:
action - The active SPA action instance
url - Current URL (pathname + search)
callback - Called with breadcrumb data on each update
Returns:
Function - Call to cancel streaming (e.g., on navigation)
Callback receives:
{
title: "Page Title" | null,
chain: [
{ url: "/path", label: "Label" | null,
is_active: false, resolved: true },
{ url: "/path/sub", label: null,
is_active: true, resolved: false }
],
fully_resolved: true | false
}
When label is null, the Breadcrumb_Nav component displays "------"
in muted text as a placeholder.
ACTION METHODS ACTION METHODS
page_title() page_title()
@@ -119,47 +154,46 @@ ACTION METHODS
} }
LAYOUT INTEGRATION LAYOUT INTEGRATION
The layout is responsible for building the breadcrumb chain and rendering The layout uses Rsx_Breadcrumb_Resolver.stream() to progressively render
it. This happens in the layout's on_action() method. breadcrumbs as data becomes available.
Building the Chain: Complete Example:
async _update_header_from_action() { class My_Spa_Layout extends Spa_Layout {
const chain = []; on_create() {
this._breadcrumb_cancel = null;
// Start with current action's active label
chain.push({
label: await this.action.breadcrumb_label_active(),
url: null // Active item has no link
});
// Walk up parent chain
let parent_url = await this.action.breadcrumb_parent();
while (parent_url) {
const parent = await Spa.load_detached_action(parent_url, {
use_cached_data: true
});
if (!parent) break;
chain.unshift({
label: await parent.breadcrumb_label(),
url: parent_url
});
parent_url = await parent.breadcrumb_parent();
parent.stop(); // Clean up detached action
} }
this._set_header({ on_action(url, action_name, args) {
title: await this.action.page_title(), // Cancel any previous breadcrumb stream
breadcrumbs: chain if (this._breadcrumb_cancel) {
}); this._breadcrumb_cancel();
}
// Stream breadcrumbs progressively
this._breadcrumb_cancel = Rsx_Breadcrumb_Resolver.stream(
this.action,
url,
(data) => this._render_breadcrumbs(data)
);
}
_render_breadcrumbs(data) {
// Update title (null = still loading)
if (data.title !== null) {
this.$sid('header_title').html(data.title);
}
// Update breadcrumbs
this.$sid('header_breadcrumbs').component('Breadcrumb_Nav', {
crumbs: data.chain
});
}
} }
Calling from on_action(): The callback is called multiple times:
async on_action(url, action_name, args) { - First with cached data (if full cache hit)
await this._update_header_from_action(); - Then with validated active action data
} - Then progressively as parent labels resolve
BREADCRUMB_NAV COMPONENT BREADCRUMB_NAV COMPONENT
Template: Template:
@@ -171,11 +205,21 @@ BREADCRUMB_NAV COMPONENT
%> %>
<% if (is_last || !crumb.url) { %> <% if (is_last || !crumb.url) { %>
<li class="breadcrumb-item active" aria-current="page"> <li class="breadcrumb-item active" aria-current="page">
<%= crumb.label %> <% if (crumb.label === null) { %>
<span class="breadcrumb-placeholder">------</span>
<% } else { %>
<%= crumb.label %>
<% } %>
</li> </li>
<% } else { %> <% } else { %>
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a href="<%= crumb.url %>"><%= crumb.label %></a> <a href="<%= crumb.url %>">
<% if (crumb.label === null) { %>
<span class="breadcrumb-placeholder">------</span>
<% } else { %>
<%= crumb.label %>
<% } %>
</a>
</li> </li>
<% } %> <% } %>
<% } %> <% } %>
@@ -270,21 +314,46 @@ DOCUMENT TITLE
- RSX - John Smith - RSX - John Smith
- RSX - Edit: John Smith - RSX - Edit: John Smith
CACHING
The breadcrumb resolver maintains an in-memory cache keyed by URL:
Cache Structure:
{ label, label_active, parent_url }
Cache Behavior:
- Parent links: Use cache if available, skip detached action load
- Active link: Show cached value immediately, then validate with
fresh data from live action
- Fresh values overwrite cached values
Cache Invalidation:
- Visiting a page updates cache for that URL only
- Window reload clears entire cache
- No TTL - cache persists for session duration
- Parent links are never re-validated (only active page validates)
Example Timeline (first visit to /contacts/123):
t=0ms Cache miss for /contacts/123, skip Phase 1
t=50ms Active action resolves, update cache
t=51ms Walk chain: /contacts is NOT cached, load detached
t=100ms /contacts resolves, cache and render chain
t=101ms fully_resolved: true
Example Timeline (second visit to /contacts/123):
t=0ms Full cache hit, render immediately
t=1ms User sees breadcrumbs instantly
t=50ms Active action validates (values unchanged)
t=51ms No re-render needed
PERFORMANCE CONSIDERATIONS PERFORMANCE CONSIDERATIONS
Parent Chain Loading: Progressive Loading:
Spa.load_detached_action() with use_cached_data: true uses cached The resolver doesn't wait for all data before rendering. It emits
data from previous visits when available, minimizing network requests. updates as each piece of data becomes available, showing "------"
placeholders for pending labels.
When visiting Contacts > John Smith > Edit Contact for the first time: Detached Action Cleanup:
1. Edit page loads with fresh data (required) The resolver automatically stops detached actions after extracting
2. View page loads detached with use_cached_data (may hit cache) breadcrumb data to prevent memory leaks.
3. Index page loads detached with use_cached_data (may hit cache)
Subsequent breadcrumb renders reuse cached data.
Stopping Detached Actions:
Always call parent.stop() after extracting breadcrumb data to clean
up event listeners and prevent memory leaks.
DEFAULT METHODS DEFAULT METHODS
If an action doesn't define breadcrumb methods, defaults apply: If an action doesn't define breadcrumb methods, defaults apply:

View File

@@ -282,6 +282,7 @@ return [
'app/RSpade/Core', // Core framework classes (runtime essentials) 'app/RSpade/Core', // Core framework classes (runtime essentials)
'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.) 'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.)
'app/RSpade/Bundles', // Third-party bundles 'app/RSpade/Bundles', // Third-party bundles
'app/RSpade/Breadcrumbs', // Progressive breadcrumb resolution
'app/RSpade/CodeQuality', // Code quality rules and checks 'app/RSpade/CodeQuality', // Code quality rules and checks
'app/RSpade/Lib', // UI features (Flash alerts, etc.) 'app/RSpade/Lib', // UI features (Flash alerts, etc.)
'app/RSpade/temp', // Framework developer testing directory 'app/RSpade/temp', // Framework developer testing directory