diff --git a/packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts b/packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts new file mode 100644 index 00000000000..63a3e819513 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/-router-helpers-utils.ts @@ -0,0 +1,3 @@ +export function isMissing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts new file mode 100644 index 00000000000..18e1f69b796 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-active.ts @@ -0,0 +1,56 @@ +/** + The `{{isActive}}` helper returns `true` if the given route (and optional + models / query params) matches the application's current route state — the + same logic that `` uses to apply its `active` CSS class. + + ```javascript + import { isActive } from '@ember/routing'; + ``` + + ```gjs + About + ``` + + With a dynamic segment: + + ```gjs + {{isActive "post" this.post}} + ``` + + With query params: + + ```gjs + {{isActive "posts" queryParams=(hash page=2)}} + ``` + + Returns `false` if the route name or any model is null/undefined (loading state). + + @method is-active + @for Ember.Templates.helpers + @public +*/ +import type RouterService from '@ember/routing/router-service'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; +import { isMissing } from './-router-helpers-utils'; + +export default class IsActiveHelper extends Helper { + @service('router') declare private router: RouterService; + + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): boolean { + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + // Also track currentRouteName: during loading/error substates the URL + // doesn't change but the active route name does, so isActive() alone + // (which only consumes currentURL) would miss those transitions. + void this.router.currentRouteName; + + const args = queryParams ? [...models, { queryParams }] : [...models]; + return this.router.isActive(routeName, ...args); + } +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts new file mode 100644 index 00000000000..9eddb7bb18a --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-loading.ts @@ -0,0 +1,26 @@ +/** + The `{{isLoading}}` helper returns `true` if the route name or any of the + passed models are null or undefined, mirroring the loading state that + `` detects when it renders `#` as the href. + + ```javascript + import { isLoading, urlFor } from '@ember/routing'; + ``` + + ```gjs + {{#if (isLoading "post" this.post)}} + Loading… + {{else}} + {{this.post.title}} + {{/if}} + ``` + + @method is-loading + @for Ember.Templates.helpers + @public +*/ +import { isMissing } from './-router-helpers-utils'; + +export default function isLoading(routeName: unknown, ...models: unknown[]): boolean { + return !routeName || models.some(isMissing); +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts new file mode 100644 index 00000000000..0dc5e674efb --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-in.ts @@ -0,0 +1,58 @@ +/** + The `{{isTransitioningIn}}` helper returns `true` when the application is + currently transitioning *into* the specified route — i.e., the route is not + yet active but will become active when the in-flight transition settles. + + This corresponds to the `ember-transitioning-in` CSS class that `` + applies during such transitions. + + ```javascript + import { isTransitioningIn } from '@ember/routing'; + ``` + + ```gjs + About + ``` + + Returns `false` when no transition is in flight or the route is already active. + + @method is-transitioning-in + @for Ember.Templates.helpers + @public +*/ +import type { Maybe } from '@glimmer/interfaces'; +import type Route from '@ember/routing/route'; +import type { RouterState, RoutingService } from '@ember/routing/-internals'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; +import { isMissing } from './-router-helpers-utils'; + +export default class IsTransitioningInHelper extends Helper { + @service('-routing') declare private routing: RoutingService; + + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): boolean { + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + const current = this.routing.currentState as Maybe; + const target = this.routing.targetState as Maybe; + + // No transition in flight. + if (isMissing(target) || current === target) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + const castedModels = models as {}[]; + const isCurrentlyActive = + !isMissing(current) && + this.routing.isActiveForRoute(castedModels, queryParams, routeName, current); + const willBeActive = this.routing.isActiveForRoute(castedModels, queryParams, routeName, target); + + return !isCurrentlyActive && willBeActive; + } +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts new file mode 100644 index 00000000000..5e9346e7375 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/is-transitioning-out.ts @@ -0,0 +1,58 @@ +/** + The `{{isTransitioningOut}}` helper returns `true` when the application is + currently transitioning *away from* the specified route — i.e., the route is + active now but will no longer be active when the in-flight transition settles. + + This corresponds to the `ember-transitioning-out` CSS class that `` + applies during such transitions. + + ```javascript + import { isTransitioningOut } from '@ember/routing'; + ``` + + ```gjs + About + ``` + + Returns `false` when no transition is in flight or the route is not currently active. + + @method is-transitioning-out + @for Ember.Templates.helpers + @public +*/ +import type { Maybe } from '@glimmer/interfaces'; +import type Route from '@ember/routing/route'; +import type { RouterState, RoutingService } from '@ember/routing/-internals'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; +import { isMissing } from './-router-helpers-utils'; + +export default class IsTransitioningOutHelper extends Helper { + @service('-routing') declare private routing: RoutingService; + + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): boolean { + if (isMissing(routeName) || models.some(isMissing)) { + return false; + } + + const current = this.routing.currentState as Maybe; + const target = this.routing.targetState as Maybe; + + // No transition in flight. + if (isMissing(target) || current === target) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + const castedModels = models as {}[]; + const isCurrentlyActive = + !isMissing(current) && + this.routing.isActiveForRoute(castedModels, queryParams, routeName, current); + const willBeActive = this.routing.isActiveForRoute(castedModels, queryParams, routeName, target); + + return isCurrentlyActive && !willBeActive; + } +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts new file mode 100644 index 00000000000..47005a102db --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/root-url.ts @@ -0,0 +1,26 @@ +/** + The `{{rootUrl}}` helper returns the application's configured `rootURL`. + + ```javascript + import { rootUrl } from '@ember/routing'; + ``` + + ```gjs + Profile + ``` + + @method root-url + @for Ember.Templates.helpers + @public +*/ +import type RouterService from '@ember/routing/router-service'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; + +export default class RootUrlHelper extends Helper { + @service('router') declare private router: RouterService; + + compute(): string { + return this.router.rootURL; + } +} diff --git a/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts new file mode 100644 index 00000000000..64f14f98349 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/url-for.ts @@ -0,0 +1,46 @@ +/** + The `{{urlFor}}` helper returns a URL string for a given route, matching the + same arguments as ``. Unlike ``, it returns a string value + rather than rendering an anchor element. + + ```javascript + import { urlFor } from '@ember/routing'; + ``` + + ```gjs + Profile + ``` + + With query params: + + ```gjs + Page 2 + ``` + + Returns `undefined` if the route name or any model is null/undefined (loading state). + + @method url-for + @for Ember.Templates.helpers + @public +*/ +import type RouterService from '@ember/routing/router-service'; +import { service } from '@ember/service'; +import Helper from '@ember/component/helper'; +import { isMissing } from './-router-helpers-utils'; + +export default class UrlForHelper extends Helper { + @service('router') declare private router: RouterService; + + compute( + [routeName, ...models]: [string | null | undefined, ...unknown[]], + { queryParams }: { queryParams?: Record } + ): string | undefined { + if (isMissing(routeName) || models.some(isMissing)) { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args: any[] = queryParams ? [...models, { queryParams }] : [...models]; + return this.router.urlFor(routeName, ...args); + } +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js new file mode 100644 index 00000000000..ea16f27af64 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/router-helpers-test.js @@ -0,0 +1,535 @@ +import { RSVP } from '@ember/-internals/runtime'; +import Route from '@ember/routing/route'; +import { precompileTemplate } from '@ember/template-compilation'; +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; +import { + urlFor, + rootUrl, + isActive, + isLoading, + isTransitioningIn, + isTransitioningOut, +} from '@ember/routing'; + +// Helpers are strict-mode only — they must be imported and passed via scope. +// None of these helpers are registered in the loose-mode resolver. + +// --------------------------------------------------------------------------- +// {{urlFor}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{urlFor}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('about'); + this.route('post', { path: '/posts/:post_id' }); + this.route('search'); + }); + } + + async ['@test generates URL for a simple route'](assert) { + this.add( + 'template:index', + precompileTemplate(`About`, { + strictMode: true, + scope: () => ({ urlFor }), + }) + ); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/about', 'generates correct href'); + } + + async ['@test generates URL for the index route'](assert) { + this.add( + 'template:index', + precompileTemplate(`Home`, { + strictMode: true, + scope: () => ({ urlFor }), + }) + ); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/', 'generates correct href for index'); + } + + async ['@test generates URL with a dynamic segment model'](assert) { + this.add( + 'template:index', + precompileTemplate(`Post`, { + strictMode: true, + scope: () => ({ urlFor }), + }) + ); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/posts/42', 'generates correct href with model'); + } + + async ['@test generates URL with query params'](assert) { + this.add( + 'template:search', + precompileTemplate( + `Search`, + { strictMode: true, scope: () => ({ urlFor }) } + ) + ); + this.add( + 'controller:search', + class extends Controller { + queryParams = ['q']; + q = null; + } + ); + + await this.visit('/search'); + assert.equal( + this.$('#link').attr('href'), + '/search?q=ember', + 'generates href with query params' + ); + } + + async ['@test returns undefined when model is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{urlFor "post" this.model}}`, { + strictMode: true, + scope: () => ({ urlFor }), + }) + ); + this.add( + 'controller:index', + class extends Controller { + model = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text().trim(), '', 'returns empty when model is null'); + } + + async ['@test updates when model changes'](assert) { + this.add( + 'template:index', + precompileTemplate(`Post`, { + strictMode: true, + scope: () => ({ urlFor }), + }) + ); + + class IndexController extends Controller { + @tracked postId = '1'; + } + this.add('controller:index', IndexController); + + await this.visit('/'); + assert.equal(this.$('#link').attr('href'), '/posts/1', 'initial href correct'); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => (controller.postId = '2')); + assert.equal(this.$('#link').attr('href'), '/posts/2', 'href updates when model changes'); + } + } +); + +// --------------------------------------------------------------------------- +// {{rootUrl}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{rootUrl}}', + class extends ApplicationTestCase { + async ['@test returns the default rootURL'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{rootUrl}}`, { + strictMode: true, + scope: () => ({ rootUrl }), + }) + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), '/', 'returns default rootURL of "/"'); + } + } +); + +// --------------------------------------------------------------------------- +// {{isActive}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{isActive}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('about'); + this.route('post', { path: '/posts/:post_id' }); + this.route('search'); + }); + } + + async ['@test returns true for the current route'](assert) { + this.add( + 'template:index', + precompileTemplate( + `{{isActive "index"}} + {{isActive "about"}}`, + { strictMode: true, scope: () => ({ isActive }) } + ) + ); + + await this.visit('/'); + assert.equal(this.$('#home-active').text(), 'true', 'index is active on /'); + assert.equal(this.$('#about-active').text(), 'false', 'about is not active on /'); + } + + async ['@test updates after navigation'](assert) { + this.add( + 'template:application', + precompileTemplate( + `{{outlet}} + {{isActive "index"}} + {{isActive "about"}}`, + { strictMode: true, scope: () => ({ isActive }) } + ) + ); + + await this.visit('/'); + assert.equal(this.$('#home-active').text(), 'true', 'index active before nav'); + assert.equal(this.$('#about-active').text(), 'false', 'about inactive before nav'); + + await this.visit('/about'); + assert.equal(this.$('#home-active').text(), 'false', 'index inactive after nav'); + assert.equal(this.$('#about-active').text(), 'true', 'about active after nav'); + } + + async ['@test returns false when any model is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{isActive "post" this.model}}`, { + strictMode: true, + scope: () => ({ isActive }), + }) + ); + this.add( + 'controller:index', + class extends Controller { + model = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'false', 'false when model is null'); + } + + async ['@test works with a dynamic segment model'](assert) { + this.add( + 'template:post', + precompileTemplate(`{{isActive "post" "42"}}`, { + strictMode: true, + scope: () => ({ isActive }), + }) + ); + + await this.visit('/posts/42'); + assert.equal(this.$('#result').text(), 'true', 'active on correct post'); + } + + async ['@test returns false for a different dynamic segment value'](assert) { + this.add( + 'template:post', + precompileTemplate(`{{isActive "post" "99"}}`, { + strictMode: true, + scope: () => ({ isActive }), + }) + ); + + await this.visit('/posts/42'); + assert.equal(this.$('#result').text(), 'false', 'false for different model id'); + } + + async ['@test stays false during loading substate (URL unchanged, route name changes)']( + assert + ) { + let aboutDefer = RSVP.defer(); + + this.add( + 'route:about', + class extends Route { + model() { + return aboutDefer.promise; + } + } + ); + + this.add( + 'template:about-loading', + precompileTemplate(`
`, { strictMode: true, scope: () => ({}) }) + ); + + this.add( + 'template:application', + precompileTemplate( + `{{outlet}}{{isActive "about"}}`, + { strictMode: true, scope: () => ({ isActive }) } + ) + ); + + await this.visit('/'); + assert.equal(this.$('#about-active').text(), 'false', 'false before navigation'); + + let visitPromise = this.visit('/about'); + // While in the loading substate, currentURL is still '/' but + // currentRouteName has changed — isActive must still return false. + assert.equal( + this.$('#about-active').text(), + 'false', + 'false during loading substate' + ); + + aboutDefer.resolve(); + await visitPromise; + assert.equal(this.$('#about-active').text(), 'true', 'true after model resolves'); + } + } +); + +// --------------------------------------------------------------------------- +// {{isLoading}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{isLoading}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('post', { path: '/posts/:post_id' }); + }); + } + + async ['@test returns false when all args are present'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{isLoading "post" "42"}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'false', 'false when route and model are present'); + } + + async ['@test returns true when model is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{isLoading "post" this.model}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) + ); + this.add( + 'controller:index', + class extends Controller { + model = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true when model is null'); + } + + async ['@test returns true when model is undefined'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{isLoading "post" this.model}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) + ); + this.add( + 'controller:index', + class extends Controller { + model = undefined; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true when model is undefined'); + } + + async ['@test returns true when routeName is null'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{isLoading this.route}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) + ); + this.add( + 'controller:index', + class extends Controller { + route = null; + } + ); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true when routeName is null'); + } + + async ['@test updates reactively when model changes'](assert) { + this.add( + 'template:index', + precompileTemplate(`{{isLoading "post" this.postId}}`, { + strictMode: true, + scope: () => ({ isLoading }), + }) + ); + + class IndexController extends Controller { + @tracked postId = null; + } + this.add('controller:index', IndexController); + + await this.visit('/'); + assert.equal(this.$('#result').text(), 'true', 'true initially (null model)'); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => (controller.postId = '42')); + assert.equal(this.$('#result').text(), 'false', 'false after model is set'); + } + } +); + +// --------------------------------------------------------------------------- +// {{isTransitioningIn}} and {{isTransitioningOut}} +// --------------------------------------------------------------------------- + +moduleFor( + 'Router helpers: {{isTransitioningIn}} and {{isTransitioningOut}}', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.aboutDefer = RSVP.defer(); + let _this = this; + + this.router.map(function () { + this.route('about'); + this.route('other'); + }); + + this.add( + 'route:about', + class extends Route { + model() { + return _this.aboutDefer.promise; + } + } + ); + + this.add( + 'template:application', + precompileTemplate( + `{{outlet}} + {{isTransitioningIn "index"}} + {{isTransitioningOut "index"}} + {{isTransitioningIn "about"}} + {{isTransitioningOut "about"}}`, + { strictMode: true, scope: () => ({ isTransitioningIn, isTransitioningOut }) } + ) + ); + } + + afterEach() { + super.afterEach(); + this.aboutDefer = null; + } + + async ['@test all false when no transition is in flight'](assert) { + await this.visit('/'); + + assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in'); + assert.equal(this.$('#index-out').text(), 'false', 'index not transitioning-out'); + assert.equal(this.$('#about-in').text(), 'false', 'about not transitioning-in'); + assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out'); + } + + ['@test correct values during a deferred transition'](assert) { + return this.visit('/').then(() => { + runTask(() => this.visit('/about')); + + assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in'); + assert.equal( + this.$('#index-out').text(), + 'true', + 'index is transitioning-out (leaving index)' + ); + assert.equal( + this.$('#about-in').text(), + 'true', + 'about is transitioning-in (entering about)' + ); + assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out'); + + runTask(() => this.aboutDefer.resolve()); + + assert.equal(this.$('#index-in').text(), 'false', 'index not transitioning-in after'); + assert.equal(this.$('#index-out').text(), 'false', 'index not transitioning-out after'); + assert.equal(this.$('#about-in').text(), 'false', 'about not transitioning-in after'); + assert.equal(this.$('#about-out').text(), 'false', 'about not transitioning-out after'); + }); + } + } +); + +moduleFor( + 'Router helpers: {{isTransitioningIn}} and {{isTransitioningOut}} with null models', + class extends ApplicationTestCase { + constructor(...args) { + super(...args); + + this.router.map(function () { + this.route('about'); + }); + + this.add( + 'template:application', + precompileTemplate( + `{{outlet}} + {{isTransitioningIn "about" this.model}} + {{isTransitioningOut "about" this.model}}`, + { strictMode: true, scope: () => ({ isTransitioningIn, isTransitioningOut }) } + ) + ); + this.add( + 'controller:application', + class extends Controller { + model = null; + } + ); + } + + async ['@test returns false when model is null'](assert) { + await this.visit('/'); + assert.equal(this.$('#result-in').text(), 'false', 'isTransitioningIn false with null model'); + assert.equal( + this.$('#result-out').text(), + 'false', + 'isTransitioningOut false with null model' + ); + } + } +); diff --git a/packages/@ember/routing/index.ts b/packages/@ember/routing/index.ts index be0dd6e698f..62c7fc5af70 100644 --- a/packages/@ember/routing/index.ts +++ b/packages/@ember/routing/index.ts @@ -1 +1,7 @@ export { default as LinkTo } from '@ember/-internals/glimmer/lib/components/link-to'; +export { default as urlFor } from '@ember/-internals/glimmer/lib/helpers/url-for'; +export { default as rootUrl } from '@ember/-internals/glimmer/lib/helpers/root-url'; +export { default as isActive } from '@ember/-internals/glimmer/lib/helpers/is-active'; +export { default as isLoading } from '@ember/-internals/glimmer/lib/helpers/is-loading'; +export { default as isTransitioningIn } from '@ember/-internals/glimmer/lib/helpers/is-transitioning-in'; +export { default as isTransitioningOut } from '@ember/-internals/glimmer/lib/helpers/is-transitioning-out'; diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index db1994ff30a..75644866509 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -253,6 +253,10 @@ module.exports = { 'if', 'in-element', 'includes', + 'is-active', + 'is-loading', + 'is-transitioning-in', + 'is-transitioning-out', 'incrementProperty', 'indexOf', 'info', @@ -432,6 +436,7 @@ module.exports = { 'rethrow', 'retry', 'reverseObjects', + 'root-url', 'rootElement', 'rootURL', 'routeDidChange', @@ -517,6 +522,7 @@ module.exports = { 'unshiftObjects', 'unsubscribe', 'url', + 'url-for', 'urlFor', 'validationCache', 'visit',