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:
291
app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
Normal file
291
app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user