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/SPA',
'app/RSpade/Core/Debug', // Debug components (JS_Tree_Debug_*)
'app/RSpade/Breadcrumbs', // Progressive breadcrumb resolution
'app/RSpade/Lib',
],
];

View File

@@ -787,9 +787,19 @@ class Spa {
// Check if current container has a component with target class name
// 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
const existing_component = $existing.component();
@@ -848,19 +858,19 @@ class Spa {
// Create 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)
// This allows layout navigation to update immediately while action loads
await component.rendered();
if (i === 0) {
// Top-level layout - set reference immediately
Spa.layout = component;
}
if (is_last) {
// This is the action
// This is the action - set reference but don't wait
Spa.action = component;
} else {
// This is a layout
if (i === 0) {
// Top-level layout
Spa.layout = component;
}
// Wait for render to complete (not full ready - we don't need child data to load)
// This allows layout navigation to update immediately while action loads
await component.rendered();
// Move container to this layout's $content for next iteration
$current_container = component.$sid('content');

View File

@@ -1,9 +1,21 @@
BREADCRUMBS(3) RSX Framework Manual BREADCRUMBS(3)
NAME
breadcrumbs - Layout-managed breadcrumb navigation system for SPA actions
breadcrumbs - Progressive breadcrumb navigation with caching
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:
class Contacts_View_Action extends Spa_Action {
@@ -15,28 +27,51 @@ SYNOPSIS
}
}
Template Component:
<Breadcrumb_Nav $crumbs="<%= JSON.stringify(chain) %>" />
DESCRIPTION
The RSX breadcrumb system provides hierarchical navigation through action
methods that define breadcrumb metadata. Unlike traditional breadcrumb
systems where each page hardcodes its full breadcrumb path, RSX uses
parent URL traversal to automatically build the breadcrumb chain.
The RSX breadcrumb system provides progressive breadcrumb resolution with
caching for instant SPA navigation. Breadcrumbs render immediately using
cached data while validating against live action data in the background.
Key characteristics:
- Actions define breadcrumb behavior through async methods
- Layout walks up the parent chain using Spa.load_detached_action()
- Each parent contributes its breadcrumb_label() to the chain
- Active page uses breadcrumb_label_active() for descriptive text
- Document title set from page_title() method
- Instant render from cache when all chain links are cached
- Progressive label updates for uncached pages (shows "------" placeholders)
- Active page always validates against live action data
- In-memory cache persists for session (cleared on page reload)
This approach provides:
- Dynamic breadcrumb content based on loaded data
- Automatic chain building without hardcoding paths
- Consistent breadcrumb behavior across all SPA actions
- Descriptive active breadcrumb text for root pages
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
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
page_title()
@@ -119,47 +154,46 @@ ACTION METHODS
}
LAYOUT INTEGRATION
The layout is responsible for building the breadcrumb chain and rendering
it. This happens in the layout's on_action() method.
The layout uses Rsx_Breadcrumb_Resolver.stream() to progressively render
breadcrumbs as data becomes available.
Building the Chain:
async _update_header_from_action() {
const chain = [];
// 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
Complete Example:
class My_Spa_Layout extends Spa_Layout {
on_create() {
this._breadcrumb_cancel = null;
}
this._set_header({
title: await this.action.page_title(),
breadcrumbs: chain
});
on_action(url, action_name, args) {
// Cancel any previous breadcrumb stream
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():
async on_action(url, action_name, args) {
await this._update_header_from_action();
}
The callback is called multiple times:
- First with cached data (if full cache hit)
- Then with validated active action data
- Then progressively as parent labels resolve
BREADCRUMB_NAV COMPONENT
Template:
@@ -171,11 +205,21 @@ BREADCRUMB_NAV COMPONENT
%>
<% if (is_last || !crumb.url) { %>
<li class="breadcrumb-item active" aria-current="page">
<%= crumb.label %>
<% if (crumb.label === null) { %>
<span class="breadcrumb-placeholder">------</span>
<% } else { %>
<%= crumb.label %>
<% } %>
</li>
<% } else { %>
<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>
<% } %>
<% } %>
@@ -270,21 +314,46 @@ DOCUMENT TITLE
- RSX - 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
Parent Chain Loading:
Spa.load_detached_action() with use_cached_data: true uses cached
data from previous visits when available, minimizing network requests.
Progressive Loading:
The resolver doesn't wait for all data before rendering. It emits
updates as each piece of data becomes available, showing "------"
placeholders for pending labels.
When visiting Contacts > John Smith > Edit Contact for the first time:
1. Edit page loads with fresh data (required)
2. View page loads detached with use_cached_data (may hit cache)
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.
Detached Action Cleanup:
The resolver automatically stops detached actions after extracting
breadcrumb data to prevent memory leaks.
DEFAULT METHODS
If an action doesn't define breadcrumb methods, defaults apply: