From 366158b698d8a4b5b3208f35f6fa6f6dc98421d7 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Tue, 20 Jan 2026 19:54:14 -0800 Subject: [PATCH 1/5] Add time frames to the Top Contributors list Note that these queries are a bit slow. I don't think these new subpages will be accessed anywhere near often enough for their ~2sec query time to be a big deal. But if we start getting into trouble with it (e.g. someone starts slamming us for fun), we can look into how how cache these values over time. --- Gemfile | 1 + Gemfile.lock | 5 + .../stylesheets/users/_top_contributors.sass | 10 +- app/controllers/users_controller.rb | 5 +- app/models/user.rb | 45 +++ app/views/users/top_contributors.html.haml | 11 +- config/locales/en-MEEP.yml | 5 + config/locales/en.yml | 5 + config/locales/es.yml | 5 + config/locales/pt.yml | 5 + ...to_contributions_user_id_and_created_at.rb | 6 + db/openneo_id_schema.rb | 32 +- db/schema.rb | 205 ++++++------ db/seeds/top_contributors_sample_data.rb | 72 +++++ spec/controllers/users_controller_spec.rb | 119 +++++++ spec/models/user_spec.rb | 293 ++++++++++++++++++ .../cache/rails-controller-testing-1.0.5.gem | Bin 0 -> 39936 bytes 17 files changed, 703 insertions(+), 121 deletions(-) create mode 100644 db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb create mode 100644 db/seeds/top_contributors_sample_data.rb create mode 100644 spec/controllers/users_controller_spec.rb create mode 100644 spec/models/user_spec.rb create mode 100644 vendor/cache/rails-controller-testing-1.0.5.gem diff --git a/Gemfile b/Gemfile index cf82fc06..0560cc29 100644 --- a/Gemfile +++ b/Gemfile @@ -87,4 +87,5 @@ gem "shell", "~> 0.8.1" # For automated tests. gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test] +gem 'rails-controller-testing', group: [:test] gem "webmock", "~> 3.24", group: [:test] diff --git a/Gemfile.lock b/Gemfile.lock index e31c4707..bb90cac4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -341,6 +341,10 @@ GEM activesupport (= 8.1.2) bundler (>= 1.15.0) railties (= 8.1.2) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -505,6 +509,7 @@ DEPENDENCIES rack-attack (~> 6.7) rack-mini-profiler (~> 4.0, >= 4.0.1) rails (~> 8.0, >= 8.0.1) + rails-controller-testing rails-i18n (~> 8.0, >= 8.0.1) rdiscount (~> 2.2, >= 2.2.7.1) rspec-rails (~> 8.0, >= 8.0.2) diff --git a/app/assets/stylesheets/users/_top_contributors.sass b/app/assets/stylesheets/users/_top_contributors.sass index 336b8d22..f6e6faea 100644 --- a/app/assets/stylesheets/users/_top_contributors.sass +++ b/app/assets/stylesheets/users/_top_contributors.sass @@ -2,7 +2,15 @@ body.users-top_contributors text-align: center - + + .timeframe-nav + margin: 1em 0 + display: flex + justify-content: center + gap: 1em + list-style: none + padding: 0 + #top-contributors border: spacing: 0 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c5e636ee..ad05b940 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,7 +14,10 @@ class UsersController < ApplicationController end def top_contributors - @users = User.top_contributors.paginate :page => params[:page], :per_page => 20 + valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s) + @timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time' + @users = User.top_contributors_for(@timeframe.to_sym) + .paginate(page: params[:page], per_page: 20) end def edit diff --git a/app/models/user.rb b/app/models/user.rb index a70a2d81..4960f99e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,51 @@ class User < ApplicationRecord scope :top_contributors, -> { order('points DESC').where('points > 0') } + VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week] + + scope :top_contributors_for, ->(timeframe = :all_time) { + case timeframe.to_sym + when :all_time + top_contributors # Use existing efficient scope + else + top_contributors_by_period(timeframe) + end + } + + def self.top_contributors_by_period(timeframe) + start_date = case timeframe.to_sym + when :this_week then 1.week.ago + when :this_month then 1.month.ago + when :this_year then 1.year.ago + else raise ArgumentError, "Invalid timeframe: #{timeframe}" + end + + # Build the CASE statement dynamically from Contribution::POINT_VALUES + point_case = Contribution::POINT_VALUES.map { |type, points| + "WHEN #{connection.quote(type)} THEN #{points}" + }.join("\n ") + + select( + 'users.*', + "COALESCE(SUM( + CASE contributions.contributed_type + #{point_case} + END + ), 0) AS period_points" + ) + .joins('INNER JOIN contributions ON contributions.user_id = users.id') + .where('contributions.created_at >= ?', start_date) + .group('users.id') + .having('period_points > 0') + .order('period_points DESC, users.id ASC') + end + + # Virtual attribute reader for dynamically calculated points (from time-period queries). + # Falls back to the denormalized `points` column when not calculated. + def period_points + attributes['period_points'] || points + end + after_update :sync_name_with_auth_user!, if: :saved_change_to_name? after_update :log_trade_activity, if: -> user { (user.saved_change_to_owned_closet_hangers_visibility? && diff --git a/app/views/users/top_contributors.html.haml b/app/views/users/top_contributors.html.haml index c4bcb33f..8c32ca80 100644 --- a/app/views/users/top_contributors.html.haml +++ b/app/views/users/top_contributors.html.haml @@ -1,4 +1,13 @@ - title t('.title') + +%ul.timeframe-nav + - ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf| + %li + - if @timeframe == tf + %strong= t(".timeframes.#{tf}") + - else + = link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf) + = will_paginate @users %table#top-contributors %thead @@ -11,5 +20,5 @@ %tr %th{:scope => 'row'}= @users.offset + rank + 1 %td= link_to user.name, user_contributions_path(user) - %td= user.points + %td= user.period_points = will_paginate @users diff --git a/config/locales/en-MEEP.yml b/config/locales/en-MEEP.yml index 8f09ff72..aa3b8f04 100644 --- a/config/locales/en-MEEP.yml +++ b/config/locales/en-MEEP.yml @@ -640,6 +640,11 @@ en-MEEP: rank: Reep user: Meepit points: Peeps + timeframes: + all_time: All Meep + this_year: Meeps Year + this_month: Meeps Month + this_week: Meeps Week update: success: Settings successfully meeped. diff --git a/config/locales/en.yml b/config/locales/en.yml index a96038df..8e71dcf5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -783,6 +783,11 @@ en: rank: Rank user: User points: Points + timeframes: + all_time: All Time + this_year: This Year + this_month: This Month + this_week: This Week update: success: Settings successfully saved. diff --git a/config/locales/es.yml b/config/locales/es.yml index e655dbd2..eb175919 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -505,6 +505,11 @@ es: rank: Puesto user: Usuario points: Puntos + timeframes: + all_time: Todo el Tiempo + this_year: Este Año + this_month: Este Mes + this_week: Esta Semana update: success: Ajustes guardados correctamente. invalid: "No hemos podido guardar los ajustes: %{errors}" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 90b5331b..8dffb0f0 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -499,6 +499,11 @@ pt: rank: Rank user: Usuário points: Pontos + timeframes: + all_time: Todo o Tempo + this_year: Este Ano + this_month: Este Mês + this_week: Esta Semana update: success: Configurações salvas com sucesso invalid: "Não foi possível salvar as configurações: %{errors}" diff --git a/db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb b/db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb new file mode 100644 index 00000000..7b26ea7c --- /dev/null +++ b/db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb @@ -0,0 +1,6 @@ +class AddIndexToContributionsUserIdAndCreatedAt < ActiveRecord::Migration[8.1] + def change + add_index :contributions, [:user_id, :created_at], + name: 'index_contributions_on_user_id_and_created_at' + end +end diff --git a/db/openneo_id_schema.rb b/db/openneo_id_schema.rb index 250d7a4d..3b0af5b4 100644 --- a/db/openneo_id_schema.rb +++ b/db/openneo_id_schema.rb @@ -10,28 +10,28 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_04_08_120359) do +ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t| - t.string "name", limit: 30, null: false - t.string "encrypted_password", limit: 64 + t.datetime "created_at", precision: nil + t.datetime "current_sign_in_at", precision: nil + t.string "current_sign_in_ip" t.string "email", limit: 50 + t.string "encrypted_password", limit: 64 + t.integer "failed_attempts", default: 0 + t.datetime "last_sign_in_at", precision: nil + t.string "last_sign_in_ip" + t.datetime "locked_at", precision: nil + t.string "name", limit: 30, null: false + t.string "neopass_email" t.string "password_salt", limit: 32 + t.string "provider" + t.datetime "remember_created_at" + t.datetime "reset_password_sent_at", precision: nil t.string "reset_password_token" t.integer "sign_in_count", default: 0 - t.datetime "current_sign_in_at", precision: nil - t.datetime "last_sign_in_at", precision: nil - t.string "current_sign_in_ip" - t.string "last_sign_in_ip" - t.integer "failed_attempts", default: 0 - t.string "unlock_token" - t.datetime "locked_at", precision: nil - t.datetime "created_at", precision: nil - t.datetime "updated_at", precision: nil - t.datetime "reset_password_sent_at", precision: nil - t.datetime "remember_created_at" - t.string "provider" t.string "uid" - t.string "neopass_email" + t.string "unlock_token" + t.datetime "updated_at", precision: nil t.index ["email"], name: "index_users_on_email", unique: true t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/db/schema.rb b/db/schema.rb index 809c71ff..0b6d3206 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,50 +10,50 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do +ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "species_id", null: false - t.integer "color_id", null: false t.integer "body_id", null: false + t.integer "color_id", null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.string "series_name" - t.string "thumbnail_url", null: false t.string "full_name" + t.string "series_name" + t.integer "species_id", null: false + t.string "thumbnail_url", null: false + t.datetime "updated_at", precision: nil, null: false t.index ["color_id"], name: "index_alt_styles_on_color_id" t.index ["species_id"], name: "index_alt_styles_on_species_id" end create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.string "short_name", limit: 10, null: false - t.string "name", limit: 40, null: false - t.text "icon", size: :long, null: false t.text "gateway", size: :long, null: false + t.text "icon", size: :long, null: false + t.string "name", limit: 40, null: false t.string "secret", limit: 64, null: false + t.string "short_name", limit: 10, null: false end create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "progress", default: 0, null: false - t.integer "goal", null: false t.boolean "active", null: false - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.boolean "advertised", default: true, null: false + t.datetime "created_at", precision: nil, null: false t.text "description", size: :long, null: false - t.string "purpose", default: "our hosting costs this year", null: false - t.string "theme_id", default: "hug", null: false - t.text "thanks", size: :long + t.integer "goal", null: false t.string "name" + t.integer "progress", default: 0, null: false + t.string "purpose", default: "our hosting costs this year", null: false + t.text "thanks", size: :long + t.string "theme_id", default: "hug", null: false + t.datetime "updated_at", precision: nil, null: false end create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "item_id" - t.integer "user_id" - t.integer "quantity" t.datetime "created_at", precision: nil - t.datetime "updated_at", precision: nil - t.boolean "owned", default: true, null: false + t.integer "item_id" t.integer "list_id" + t.boolean "owned", default: true, null: false + t.integer "quantity" + t.datetime "updated_at", precision: nil + t.integer "user_id" t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned" t.index ["list_id"], name: "index_closet_hangers_on_list_id" t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226" @@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.string "name" - t.text "description", size: :long - t.integer "user_id" - t.boolean "hangers_owned", null: false t.datetime "created_at", precision: nil + t.text "description", size: :long + t.boolean "hangers_owned", null: false + t.string "name" t.datetime "updated_at", precision: nil + t.integer "user_id" t.integer "visibility", default: 1, null: false t.index ["user_id"], name: "index_closet_lists_on_user_id" end create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.boolean "basic" - t.boolean "standard" t.string "name", null: false t.string "pb_item_name" t.string "pb_item_thumbnail_url" + t.boolean "standard" end create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.string "contributed_type", limit: 8, null: false t.integer "contributed_id", null: false - t.integer "user_id", null: false + t.string "contributed_type", limit: 8, null: false t.datetime "created_at", precision: nil, null: false + t.integer "user_id", null: false t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type" + t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at" t.index ["user_id"], name: "index_contributions_on_user_id" end create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false t.integer "donation_id", null: false t.integer "outfit_id" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false end create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.integer "amount", null: false + t.integer "campaign_id", null: false t.string "charge_id", null: false - t.integer "user_id" + t.datetime "created_at", precision: nil, null: false + t.string "donor_email" t.string "donor_name" t.string "secret" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "donor_email" - t.integer "campaign_id", null: false + t.integer "user_id" end create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| + t.datetime "created_at", precision: nil + t.boolean "is_worn" t.integer "item_id" t.integer "outfit_id" - t.boolean "is_worn" - t.datetime "created_at", precision: nil t.datetime "updated_at", precision: nil t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id" t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn" end create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.text "zones_restrict", size: :medium, null: false - t.text "thumbnail_url", size: :long, null: false + t.text "cached_compatible_body_ids", default: "" + t.string "cached_occupied_zone_ids", default: "" + t.boolean "cached_predicted_fully_modeled", default: false, null: false t.string "category", limit: 50 - t.string "type", limit: 50 - t.integer "rarity_index", limit: 2 - t.integer "price", limit: 3, null: false - t.integer "weight_lbs", limit: 2 - t.text "species_support_ids", size: :long t.datetime "created_at", precision: nil - t.datetime "updated_at", precision: nil + t.text "description", size: :medium, null: false + t.integer "dyeworks_base_item_id" t.boolean "explicitly_body_specific", default: false, null: false + t.boolean "is_manually_nc", default: false, null: false t.integer "manual_special_color_id" t.column "modeling_status_hint", "enum('done','glitchy')" - t.boolean "is_manually_nc", default: false, null: false t.string "name", null: false - t.text "description", size: :medium, null: false + t.integer "price", limit: 3, null: false t.string "rarity", default: "", null: false - t.integer "dyeworks_base_item_id" - t.string "cached_occupied_zone_ids", default: "" - t.text "cached_compatible_body_ids", default: "" - t.boolean "cached_predicted_fully_modeled", default: false, null: false + t.integer "rarity_index", limit: 2 + t.text "species_support_ids", size: :long + t.text "thumbnail_url", size: :long, null: false + t.string "type", limit: 50 + t.datetime "updated_at", precision: nil + t.integer "weight_lbs", limit: 2 + t.text "zones_restrict", size: :medium, null: false t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id" t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id" t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at" @@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "user_id", null: false t.integer "series", null: false t.integer "token", null: false + t.integer "user_id", null: false t.index ["user_id", "series"], name: "login_cookies_user_id_and_series" t.index ["user_id"], name: "login_cookies_user_id" end @@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| - t.integer "item_id", null: false - t.integer "price", null: false - t.integer "discount_price" + t.datetime "created_at", null: false t.datetime "discount_begins_at" t.datetime "discount_ends_at" - t.datetime "created_at", null: false + t.integer "discount_price" + t.integer "item_id", null: false + t.integer "price", null: false t.datetime "updated_at", null: false t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true end create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "user_id" - t.string "neopets_username" t.datetime "created_at", precision: nil, null: false + t.string "neopets_username" t.datetime "updated_at", precision: nil, null: false + t.integer "user_id" end create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "pet_state_id" - t.integer "user_id" - t.datetime "created_at", precision: nil - t.datetime "updated_at", precision: nil - t.string "name" - t.boolean "starred", default: false, null: false - t.string "image" - t.string "image_layers_hash" - t.boolean "image_enqueued", default: false, null: false t.bigint "alt_style_id" + t.datetime "created_at", precision: nil + t.string "image" + t.boolean "image_enqueued", default: false, null: false + t.string "image_layers_hash" + t.string "name" + t.integer "pet_state_id" + t.boolean "starred", default: false, null: false + t.datetime "updated_at", precision: nil + t.integer "user_id" t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id" t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id" t.index ["user_id"], name: "index_outfits_on_user_id" @@ -199,40 +200,40 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.integer "parent_id", null: false - t.integer "swf_asset_id", null: false t.string "parent_type", limit: 8, null: false + t.integer "swf_asset_id", null: false t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type" t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id" end create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.string "pet_name", limit: 20, null: false t.text "amf", size: :long, null: false t.datetime "created_at", precision: nil, null: false + t.string "pet_name", limit: 20, null: false end create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "pet_type_id", null: false - t.text "swf_asset_ids", size: :medium, null: false - t.boolean "female" - t.integer "mood_id" - t.boolean "unconverted" - t.boolean "labeled", default: false, null: false - t.boolean "glitched", default: false, null: false t.string "artist_neopets_username" t.datetime "created_at" + t.boolean "female" + t.boolean "glitched", default: false, null: false + t.boolean "labeled", default: false, null: false + t.integer "mood_id" + t.integer "pet_type_id", null: false + t.text "swf_asset_ids", size: :medium, null: false + t.boolean "unconverted" t.datetime "updated_at" t.index ["pet_type_id"], name: "pet_states_pet_type_id" end create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.integer "color_id", null: false - t.integer "species_id", null: false - t.datetime "created_at", precision: nil, null: false - t.integer "body_id", limit: 2, null: false - t.string "image_hash", limit: 8 t.string "basic_image_hash" + t.integer "body_id", limit: 2, null: false + t.integer "color_id", null: false + t.datetime "created_at", precision: nil, null: false + t.string "image_hash", limit: 8 + t.integer "species_id", null: false t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id" t.index ["body_id"], name: "pet_types_body_id" t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id" @@ -252,50 +253,50 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.string "type", limit: 7, null: false + t.integer "body_id", limit: 2, null: false + t.datetime "converted_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.boolean "has_image", default: false, null: false + t.boolean "image_manual", default: false, null: false + t.boolean "image_requested", default: false, null: false + t.string "known_glitches", limit: 128, default: "" + t.text "manifest", size: :long + t.timestamp "manifest_cached_at" + t.datetime "manifest_loaded_at" + t.integer "manifest_status_code" + t.string "manifest_url" t.integer "remote_id", limit: 3, null: false + t.datetime "reported_broken_at", precision: nil + t.string "type", limit: 7, null: false t.text "url", size: :long, null: false t.integer "zone_id", null: false t.text "zones_restrict", size: :medium, null: false - t.datetime "created_at", precision: nil, null: false - t.integer "body_id", limit: 2, null: false - t.boolean "has_image", default: false, null: false - t.boolean "image_requested", default: false, null: false - t.datetime "reported_broken_at", precision: nil - t.datetime "converted_at", precision: nil - t.boolean "image_manual", default: false, null: false - t.text "manifest", size: :long - t.timestamp "manifest_cached_at" - t.string "known_glitches", limit: 128, default: "" - t.string "manifest_url" - t.datetime "manifest_loaded_at" - t.integer "manifest_status_code" t.index ["body_id"], name: "swf_assets_body_id_and_object_id" t.index ["type", "remote_id"], name: "swf_assets_type_and_id" t.index ["zone_id"], name: "idx_swf_assets_zone_id" end create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| - t.string "name", limit: 30, null: false t.integer "auth_server_id", limit: 1, null: false - t.integer "remote_id", null: false - t.integer "points", default: 0, null: false t.boolean "beta", default: false, null: false - t.string "remember_token" - t.datetime "remember_created_at", precision: nil - t.integer "owned_closet_hangers_visibility", default: 1, null: false - t.integer "wanted_closet_hangers_visibility", default: 1, null: false t.integer "contact_neopets_connection_id" t.timestamp "last_trade_activity_at" - t.boolean "support_staff", default: false, null: false + t.string "name", limit: 30, null: false + t.integer "owned_closet_hangers_visibility", default: 1, null: false + t.integer "points", default: 0, null: false + t.datetime "remember_created_at", precision: nil + t.string "remember_token" + t.integer "remote_id", null: false t.boolean "shadowbanned", default: false, null: false + t.boolean "support_staff", default: false, null: false + t.integer "wanted_closet_hangers_visibility", default: 1, null: false end create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.integer "depth" - t.integer "type_id" t.string "label", null: false t.string "plain_label", null: false + t.integer "type_id" end add_foreign_key "alt_styles", "colors" diff --git a/db/seeds/top_contributors_sample_data.rb b/db/seeds/top_contributors_sample_data.rb new file mode 100644 index 00000000..6721846a --- /dev/null +++ b/db/seeds/top_contributors_sample_data.rb @@ -0,0 +1,72 @@ +# Sample contributions for testing Top Contributors feature +# Run with: rails runner db/seeds/top_contributors_sample_data.rb + +puts "Creating sample contributions for Top Contributors testing..." + +# Find or create test users +users = [] +5.times do |i| + name = "TestContributor#{i + 1}" + user = User.find_or_create_by!(name: name) do |u| + # Create a corresponding auth_user record + auth_user = AuthUser.create!( + name: name, + email: "test#{i + 1}@example.com", + password: 'password123', + ) + u.remote_id = auth_user.id + u.auth_server_id = 1 + end + users << user +end + +# Get some existing items/pet types to contribute +items = Item.limit(10).to_a +pet_types = PetType.limit(5).to_a +swf_assets = SwfAsset.limit(5).to_a + +if items.empty? || pet_types.empty? + puts "WARNING: No items or pet types found. Create some first or contributions will be limited." +end + +# Create contributions with different time periods +# User 1: Heavy contributor this week +if items.any? + 3.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 2.days.ago) } + 5.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 5.days.ago) } +end + +# User 2: Heavy contributor this month, but not this week +if items.any? && pet_types.any? + 2.times { Contribution.create!(user: users[1], contributed: items.sample, created_at: 15.days.ago) } + 1.times { Contribution.create!(user: users[1], contributed: pet_types.sample, created_at: 20.days.ago) } +end + +# User 3: Heavy contributor this year, but not this month +if pet_types.any? + 3.times { Contribution.create!(user: users[2], contributed: pet_types.sample, created_at: 3.months.ago) } +end + +# User 4: Old contributor (only in all-time) +if items.any? + users[3].update!(points: 500) # Set points directly for all-time view + 2.times { Contribution.create!(user: users[3], contributed: items.sample, created_at: 2.years.ago) } +end + +# User 5: Mixed contributions across all periods +if items.any? && pet_types.any? + Contribution.create!(user: users[4], contributed: items.sample, created_at: 1.day.ago) + Contribution.create!(user: users[4], contributed: pet_types.sample, created_at: 10.days.ago) + Contribution.create!(user: users[4], contributed: items.sample, created_at: 2.months.ago) +end +if swf_assets.any? + Contribution.create!(user: users[4], contributed: swf_assets.sample, created_at: 4.days.ago) +end + +puts "Created sample contributions:" +puts "- #{users[0].name}: #{users[0].contributions.count} contributions (focus: this week)" +puts "- #{users[1].name}: #{users[1].contributions.count} contributions (focus: this month)" +puts "- #{users[2].name}: #{users[2].contributions.count} contributions (focus: this year)" +puts "- #{users[3].name}: #{users[3].contributions.count} contributions (focus: all-time, #{users[3].points} points)" +puts "- #{users[4].name}: #{users[4].contributions.count} contributions (mixed periods)" +puts "\nTest the feature at: http://localhost:3000/users/top-contributors" diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb new file mode 100644 index 00000000..de0c42f7 --- /dev/null +++ b/spec/controllers/users_controller_spec.rb @@ -0,0 +1,119 @@ +require_relative '../rails_helper' + +RSpec.describe UsersController, type: :controller do + include Devise::Test::ControllerHelpers + + describe 'GET #top_contributors' do + let!(:user1) { create_user('Alice', 100) } + let!(:user2) { create_user('Bob', 50) } + let!(:user3) { create_user('Charlie', 0) } + + context 'without timeframe parameter' do + it 'defaults to all_time timeframe' do + get :top_contributors + expect(assigns(:timeframe)).to eq('all_time') + end + + it 'returns users ordered by points' do + get :top_contributors + users = assigns(:users) + expect(users.to_a.map(&:id)).to eq([user1.id, user2.id]) + end + + it 'paginates results' do + get :top_contributors, params: { page: 1 } + users = assigns(:users) + expect(users).to respond_to(:total_pages) + expect(users).to respond_to(:current_page) + end + end + + context 'with valid timeframe parameter' do + it 'accepts all_time' do + get :top_contributors, params: { timeframe: 'all_time' } + expect(assigns(:timeframe)).to eq('all_time') + end + + it 'accepts this_year' do + get :top_contributors, params: { timeframe: 'this_year' } + expect(assigns(:timeframe)).to eq('this_year') + end + + it 'accepts this_month' do + get :top_contributors, params: { timeframe: 'this_month' } + expect(assigns(:timeframe)).to eq('this_month') + end + + it 'accepts this_week' do + get :top_contributors, params: { timeframe: 'this_week' } + expect(assigns(:timeframe)).to eq('this_week') + end + + it 'calls User.top_contributors_for with the timeframe' do + expect(User).to receive(:top_contributors_for).with(:this_week).and_call_original + get :top_contributors, params: { timeframe: 'this_week' } + end + end + + context 'with invalid timeframe parameter' do + it 'defaults to all_time' do + get :top_contributors, params: { timeframe: 'invalid' } + expect(assigns(:timeframe)).to eq('all_time') + end + + it 'does not raise an error' do + expect { + get :top_contributors, params: { timeframe: 'invalid' } + }.not_to raise_error + end + end + + context 'with pagination' do + before do + # Create 25 users to test pagination (per_page is 20) + 25.times do |i| + create_user("User#{i}", 100 - i) + end + end + + it 'paginates with 20 users per page' do + get :top_contributors + expect(assigns(:users).size).to eq(20) + end + + it 'supports page parameter' do + get :top_contributors, params: { page: 2 } + expect(assigns(:users).current_page).to eq(2) + end + + it 'works with timeframe and pagination together' do + get :top_contributors, params: { timeframe: 'all_time', page: 2 } + expect(assigns(:timeframe)).to eq('all_time') + expect(assigns(:users).current_page).to eq(2) + end + end + + context 'renders the correct template' do + it 'renders the top_contributors template' do + get :top_contributors + expect(response).to render_template('top_contributors') + end + + it 'returns HTTP success' do + get :top_contributors + expect(response).to have_http_status(:success) + end + end + end + + # Helper methods + def create_user(name, points = 0) + auth_user = AuthUser.create!( + name: name, + email: "#{name.downcase}@example.com", + password: 'password123', + password_confirmation: 'password123' + ) + User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1, points: points) + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 00000000..3d30994c --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,293 @@ +require_relative '../rails_helper' + +RSpec.describe User do + describe '.top_contributors_for' do + let!(:user1) { create_user('Alice') } + let!(:user2) { create_user('Bob') } + let!(:user3) { create_user('Charlie') } + + context 'with all_time timeframe' do + it 'uses the denormalized points column' do + user1.update!(points: 100) + user2.update!(points: 50) + user3.update!(points: 0) + + results = User.top_contributors_for(:all_time) + expect(results.map(&:id)).to eq([user1.id, user2.id]) + expect(results.first.points).to eq(100) + expect(results.second.points).to eq(50) + end + + it 'excludes users with zero points' do + user1.update!(points: 100) + user2.update!(points: 0) + + results = User.top_contributors_for(:all_time) + expect(results).not_to include(user2) + end + + it 'orders by points descending' do + user1.update!(points: 50) + user2.update!(points: 100) + user3.update!(points: 75) + + results = User.top_contributors_for(:all_time) + expect(results.map(&:id)).to eq([user2.id, user3.id, user1.id]) + end + end + + context 'with this_week timeframe' do + let(:item) { create_item } + + before do + # Create contributions from this week + create_contribution(user1, item, 3.days.ago) # 3 points + create_contribution(user1, item, 2.days.ago) # 3 points + + # Create contributions from last month (should be excluded) + create_contribution(user2, item, 1.month.ago) # 3 points (excluded) + end + + it 'calculates points from contributions in the last week' do + results = User.top_contributors_for(:this_week) + expect(results.first).to eq(user1) + expect(results.first.period_points).to eq(6) + end + + it 'excludes users with no recent contributions' do + results = User.top_contributors_for(:this_week) + expect(results).not_to include(user2) + end + + it 'excludes contributions older than one week' do + create_contribution(user3, item, 8.days.ago) + + results = User.top_contributors_for(:this_week) + expect(results).not_to include(user3) + end + end + + context 'with this_month timeframe' do + let(:item) { create_item } + let(:pet_type) { create_pet_type } + + before do + # User 1: contributions from this month + create_contribution(user1, item, 15.days.ago) # 3 points + create_contribution(user1, pet_type, 20.days.ago) # 15 points + + # User 2: contributions older than one month + create_contribution(user2, item, 35.days.ago) # 3 points (excluded) + end + + it 'calculates points from contributions in the last month' do + results = User.top_contributors_for(:this_month) + expect(results.first).to eq(user1) + expect(results.first.period_points).to eq(18) + end + + it 'excludes contributions older than one month' do + results = User.top_contributors_for(:this_month) + expect(results).not_to include(user2) + end + end + + context 'with this_year timeframe' do + let(:item) { create_item } + + before do + # User 1: contributions from this year + create_contribution(user1, item, 3.months.ago) # 3 points + create_contribution(user1, item, 6.months.ago) # 3 points + + # User 2: contributions older than one year + create_contribution(user2, item, 13.months.ago) # 3 points (excluded) + end + + it 'calculates points from contributions in the last year' do + results = User.top_contributors_for(:this_year) + expect(results.first).to eq(user1) + expect(results.first.period_points).to eq(6) + end + + it 'excludes contributions older than one year' do + results = User.top_contributors_for(:this_year) + expect(results).not_to include(user2) + end + end + + context 'point value calculations' do + let(:item) { create_item } + let(:pet_type) { create_pet_type } + let(:alt_style) { create_alt_style } + + it 'assigns 3 points for Item contributions' do + create_contribution(user1, item, 1.day.ago) + + results = User.top_contributors_for(:this_week) + expect(results.first.period_points).to eq(3) + end + + it 'assigns 15 points for PetType contributions' do + create_contribution(user1, pet_type, 1.day.ago) + + results = User.top_contributors_for(:this_week) + expect(results.first.period_points).to eq(15) + end + + it 'assigns 30 points for AltStyle contributions' do + create_contribution(user1, alt_style, 1.day.ago) + + results = User.top_contributors_for(:this_week) + expect(results.first.period_points).to eq(30) + end + + it 'sums multiple contribution types correctly' do + create_contribution(user1, item, 1.day.ago) # 3 points + create_contribution(user1, pet_type, 2.days.ago) # 15 points + create_contribution(user1, alt_style, 3.days.ago) # 30 points + + results = User.top_contributors_for(:this_week) + expect(results.first.period_points).to eq(48) + end + end + + context 'ordering and filtering' do + let(:item) { create_item } + + before do + # Create various contributions + 3.times { create_contribution(user1, item, 1.day.ago) } # 9 points + 5.times { create_contribution(user2, item, 2.days.ago) } # 15 points + 2.times { create_contribution(user3, item, 3.days.ago) } # 6 points + end + + it 'orders by period_points descending' do + results = User.top_contributors_for(:this_week) + expect(results.map(&:id)).to eq([user2.id, user1.id, user3.id]) + end + + it 'uses user.id as secondary sort for tied scores' do + # Create two users with same points + user4 = create_user('Dave') + user5 = create_user('Eve') + + create_contribution(user4, item, 1.day.ago) # 3 points + create_contribution(user5, item, 1.day.ago) # 3 points + + results = User.top_contributors_for(:this_week).where(id: [user4.id, user5.id]) + # Should be ordered by user.id ASC when points are tied + expect(results.first.id).to be < results.second.id + end + + it 'excludes users with zero contributions in period' do + # user3 has no contributions this week + user4 = create_user('Dave') + + results = User.top_contributors_for(:this_week) + expect(results).not_to include(user4) + end + end + + context 'with invalid timeframe' do + it 'raises ArgumentError' do + expect { User.top_contributors_by_period(:invalid) }. + to raise_error(ArgumentError, /Invalid timeframe/) + end + end + end + + describe '#period_points' do + let(:user) { create_user('Alice') } + + context 'when period_points attribute is set' do + it 'returns the calculated period_points' do + # Simulate a query that sets period_points + user_with_period = User.select('users.*, 42 AS period_points').find(user.id) + expect(user_with_period.period_points).to eq(42) + end + end + + context 'when period_points attribute is not set' do + it 'falls back to denormalized points column' do + user.update!(points: 100) + expect(user.period_points).to eq(100) + end + end + end + + # Helper methods + def create_user(name) + auth_user = AuthUser.create!( + name: name, + email: "#{name.downcase}@example.com", + password: 'password123', + password_confirmation: 'password123' + ) + User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1) + end + + def create_contribution(user, contributed, created_at) + Contribution.create!( + user: user, + contributed: contributed, + created_at: created_at + ) + end + + def create_item + # Create a minimal item for testing + Item.create!( + name: "Test Item #{SecureRandom.hex(4)}", + description: "Test item", + thumbnail_url: "http://example.com/thumb.png", + rarity: "", + price: 0, + zones_restrict: "" + ) + end + + def create_swf_asset + # Create a minimal swf_asset for testing + zone = Zone.first || Zone.create!(id: 1, label: "Test Zone", plain_label: "Test Zone", type_id: 1) + SwfAsset.create!( + type: 'object', + remote_id: SecureRandom.random_number(100000), + url: "http://example.com/test.swf", + zone_id: zone.id, + body_id: 0 + ) + end + + def create_pet_type + # Use find_or_create_by to avoid duplicate key errors + species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}") + color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}") + PetType.create!( + species_id: species.id, + color_id: color.id, + body_id: 0 + ) + end + + def create_pet_state + pet_type = create_pet_type + PetState.create!( + pet_type: pet_type, + swf_asset_ids: [] + ) + end + + def create_alt_style + # Use find_or_create_by to avoid duplicate key errors + species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}") + color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}") + AltStyle.create!( + species_id: species.id, + color_id: color.id, + body_id: 0, + series_name: "Test Series", + thumbnail_url: "http://example.com/thumb.png" + ) + end +end diff --git a/vendor/cache/rails-controller-testing-1.0.5.gem b/vendor/cache/rails-controller-testing-1.0.5.gem new file mode 100644 index 0000000000000000000000000000000000000000..870b8085cd53f24c3b7f64e0d72bc3f5e808fe11 GIT binary patch literal 39936 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 M4*YT8-{rvH03ybwTmS$7 literal 0 HcmV?d00001 From 304a7ac9e1d795705b92704b3e4b5f5f3cf4d153 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Thu, 5 Feb 2026 17:07:52 -0800 Subject: [PATCH 2/5] Add 2min items:auto_model cron job --- deploy/setup.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/deploy/setup.yml b/deploy/setup.yml index 43745812..0acd18f1 100644 --- a/deploy/setup.yml +++ b/deploy/setup.yml @@ -442,13 +442,12 @@ mode: "755" state: directory - - name: Remove 10min cron job to run `rails nc_mall:sync` + - name: Create 2min cron job to run `rails items:auto_model` become_user: impress cron: - state: absent - name: "Impress: sync NC Mall data" - minute: "*/10" - job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'" + name: "Impress: auto-model items" + minute: "*/2" + job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails items:auto_model'" - name: Create 10min cron job to run `rails neopets:import` become_user: impress From df043b939e4e802bcee7ef0c5cb78f114b05f6c4 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Thu, 5 Feb 2026 17:17:46 -0800 Subject: [PATCH 3/5] Support GET requests for /pets/load --- app/controllers/pets_controller.rb | 9 +++++---- app/helpers/application_helper.rb | 2 +- app/helpers/outfits_helper.rb | 4 ---- app/views/outfits/new.html.haml | 3 +-- config/routes.rb | 2 +- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/controllers/pets_controller.rb b/app/controllers/pets_controller.rb index 97b25329..ad9e5abf 100644 --- a/app/controllers/pets_controller.rb +++ b/app/controllers/pets_controller.rb @@ -34,9 +34,10 @@ class PetsController < ApplicationController end def destination - case (params[:destination] || params[:origin]) - when 'wardrobe' then wardrobe_path - else root_path + if request.get? + wardrobe_path + else + root_path end end @@ -59,7 +60,7 @@ class PetsController < ApplicationController path += "?name=#{params[:name]}" redirect_to path, :alert => options[:long_message] end - + format.json do render :json => options[:long_message], :status => options[:status] end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e357e6ba..6861211b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -167,7 +167,7 @@ module ApplicationHelper def origin_tag(value) hidden_field_tag 'origin', value, :id => nil end - + def open_graph(properties) if @open_graph @open_graph.merge! properties diff --git a/app/helpers/outfits_helper.rb b/app/helpers/outfits_helper.rb index a31d8587..6eca3717 100644 --- a/app/helpers/outfits_helper.rb +++ b/app/helpers/outfits_helper.rb @@ -1,8 +1,4 @@ module OutfitsHelper - def destination_tag(value) - hidden_field_tag 'destination', value, :id => nil - end - def latest_contribution_description(contribution) user = contribution.user contributed = contribution.contributed diff --git a/app/views/outfits/new.html.haml b/app/views/outfits/new.html.haml index 6e21ad96..6a8d84e5 100644 --- a/app/views/outfits/new.html.haml +++ b/app/views/outfits/new.html.haml @@ -25,8 +25,7 @@ %h1= t 'app_name' %h2= t '.tagline' - = form_tag load_pet_path, method: 'POST', class: 'primary load-pet-to-wardrobe' do - = hidden_field_tag 'destination', 'wardrobe' + = form_tag load_pet_path, method: 'GET', class: 'primary load-pet-to-wardrobe' do %fieldset %legend= t '.load_pet' = pet_name_tag class: 'main-pet-name' diff --git a/config/routes.rb b/config/routes.rb index 89aebd8c..04e61f80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -46,7 +46,7 @@ OpenneoImpressItems::Application.routes.draw do get '/alt-styles', to: redirect('/rainbow-pool/styles') # Loading and modeling pets! - post '/pets/load' => 'pets#load', :as => :load_pet + match '/pets/load' => 'pets#load', :as => :load_pet, via: [:get, :post] get '/modeling' => 'pets#bulk', :as => :bulk_pets # Contributions to our modeling database! From f9b040c20b7dfcc56c8072797e1d8e74227b110a Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Fri, 6 Feb 2026 19:03:20 -0800 Subject: [PATCH 4/5] Upgrade Idiomorph to 0.7.4 This seems to fix some morphing issues, whew! --- vendor/javascript/idiomorph.js | 2229 ++++++++++++++++++++------------ 1 file changed, 1382 insertions(+), 847 deletions(-) diff --git a/vendor/javascript/idiomorph.js b/vendor/javascript/idiomorph.js index 5f7d789d..5d1b7251 100644 --- a/vendor/javascript/idiomorph.js +++ b/vendor/javascript/idiomorph.js @@ -1,850 +1,1385 @@ -// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/dist/idiomorph.js +// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.7.4/dist/idiomorph.js + +/** + * @typedef {object} ConfigHead + * + * @property {'merge' | 'append' | 'morph' | 'none'} [style] + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {function(Element): boolean} [shouldPreserve] + * @property {function(Element): boolean} [shouldReAppend] + * @property {function(Element): boolean} [shouldRemove] + * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] + */ + +/** + * @typedef {object} ConfigCallbacks + * + * @property {function(Node): boolean} [beforeNodeAdded] + * @property {function(Node): void} [afterNodeAdded] + * @property {function(Element, Node): boolean} [beforeNodeMorphed] + * @property {function(Element, Node): void} [afterNodeMorphed] + * @property {function(Element): boolean} [beforeNodeRemoved] + * @property {function(Element): void} [afterNodeRemoved] + * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] + */ + +/** + * @typedef {object} Config + * + * @property {'outerHTML' | 'innerHTML'} [morphStyle] + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacks} [callbacks] + * @property {ConfigHead} [head] + */ + +/** + * @typedef {function} NoOp + * + * @returns {void} + */ + +/** + * @typedef {object} ConfigHeadInternal + * + * @property {'merge' | 'append' | 'morph' | 'none'} style + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {(function(Element): boolean) | NoOp} shouldPreserve + * @property {(function(Element): boolean) | NoOp} shouldReAppend + * @property {(function(Element): boolean) | NoOp} shouldRemove + * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed + */ + +/** + * @typedef {object} ConfigCallbacksInternal + * + * @property {(function(Node): boolean) | NoOp} beforeNodeAdded + * @property {(function(Node): void) | NoOp} afterNodeAdded + * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed + * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed + * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved + * @property {(function(Node): void) | NoOp} afterNodeRemoved + * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated + */ + +/** + * @typedef {object} ConfigInternal + * + * @property {'outerHTML' | 'innerHTML'} morphStyle + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacksInternal} callbacks + * @property {ConfigHeadInternal} head + */ + +/** + * @typedef {Object} IdSets + * @property {Set} persistentIds + * @property {Map>} idMap + */ + +/** + * @typedef {Function} Morph + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {undefined | Node[]} + */ // base IIFE to define idiomorph +/** + * + * @type {{defaults: ConfigInternal, morph: Morph}} + */ var Idiomorph = (function () { - 'use strict'; - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - let EMPTY_SET = new Set(); - - // default configuration values, updatable by users now - let defaults = { - morphStyle: "outerHTML", - callbacks : { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - - }, - head: { - style: 'merge', - shouldPreserve: function (elt) { - return elt.getAttribute("im-preserve") === "true"; - }, - shouldReAppend: function (elt) { - return elt.getAttribute("im-re-append") === "true"; - }, - shouldRemove: noOp, - afterHeadMorphed: noOp, - } - }; - - //============================================================================= - // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren - //============================================================================= - function morph(oldNode, newContent, config = {}) { - - if (oldNode instanceof Document) { - oldNode = oldNode.documentElement; - } - - if (typeof newContent === 'string') { - newContent = parseContent(newContent); - } - - let normalizedContent = normalizeContent(newContent); - - let ctx = createMorphContext(oldNode, normalizedContent, config); - - return morphNormalizedContent(oldNode, normalizedContent, ctx); - } - - function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { - if (ctx.head.block) { - let oldHead = oldNode.querySelector('head'); - let newHead = normalizedNewContent.querySelector('head'); - if (oldHead && newHead) { - let promises = handleHeadElement(newHead, oldHead, ctx); - // when head promises resolve, call morph again, ignoring the head tag - Promise.all(promises).then(function () { - morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { - head: { - block: false, - ignore: true - } - })); - }); - return; - } - } - - if (ctx.morphStyle === "innerHTML") { - - // innerHTML, so we are only updating the children - morphChildren(normalizedNewContent, oldNode, ctx); - return oldNode.children; - - } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { - // otherwise find the best element match in the new content, morph that, and merge its siblings - // into either side of the best match - let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - - // stash the siblings that will need to be inserted on either side of the best match - let previousSibling = bestMatch?.previousSibling; - let nextSibling = bestMatch?.nextSibling; - - // morph it - let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); - - if (bestMatch) { - // if there was a best match, merge the siblings in too and return the - // whole bunch - return insertSiblings(previousSibling, morphedNode, nextSibling); - } else { - // otherwise nothing was added to the DOM - return [] - } - } else { - throw "Do not understand how to morph style " + ctx.morphStyle; - } - } - - - /** - * @param possibleActiveElement - * @param ctx - * @returns {boolean} - */ - function ignoreValueOfActiveElement(possibleActiveElement, ctx) { - return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement; - } - - /** - * @param oldNode root node to merge content into - * @param newContent new content to merge - * @param ctx the merge context - * @returns {Element} the element that ended up in the DOM - */ - function morphOldNodeTo(oldNode, newContent, ctx) { - if (ctx.ignoreActive && oldNode === document.activeElement) { - // don't morph focused element - } else if (newContent == null) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - - oldNode.remove(); - ctx.callbacks.afterNodeRemoved(oldNode); - return null; - } else if (!isSoftMatch(oldNode, newContent)) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; - - oldNode.parentElement.replaceChild(newContent, oldNode); - ctx.callbacks.afterNodeAdded(newContent); - ctx.callbacks.afterNodeRemoved(oldNode); - return newContent; - } else { - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; - - if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) { - // ignore the head element - } else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { - handleHeadElement(newContent, oldNode, ctx); - } else { - syncNodeFrom(newContent, oldNode, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - morphChildren(newContent, oldNode, ctx); - } - } - ctx.callbacks.afterNodeMorphed(oldNode, newContent); - return oldNode; - } - } - - /** - * This is the core algorithm for matching up children. The idea is to use id sets to try to match up - * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but - * by using id sets, we are able to better match up with content deeper in the DOM. - * - * Basic algorithm is, for each node in the new content: - * - * - if we have reached the end of the old parent, append the new content - * - if the new content has an id set match with the current insertion point, morph - * - search for an id set match - * - if id set match found, morph - * - otherwise search for a "soft" match - * - if a soft match is found, morph - * - otherwise, prepend the new node before the current insertion point - * - * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved - * with the current node. See findIdSetMatch() and findSoftMatch() for details. - * - * @param {Element} newParent the parent element of the new content - * @param {Element } oldParent the old content that we are merging the new content into - * @param ctx the merge context - */ - function morphChildren(newParent, oldParent, ctx) { - - let nextNewChild = newParent.firstChild; - let insertionPoint = oldParent.firstChild; - let newChild; - - // run through all the new content - while (nextNewChild) { - - newChild = nextNewChild; - nextNewChild = newChild.nextSibling; - - // if we are at the end of the exiting parent's children, just append - if (insertionPoint == null) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.appendChild(newChild); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // if the current node has an id set match then morph - if (isIdSetMatch(newChild, insertionPoint, ctx)) { - morphOldNodeTo(insertionPoint, newChild, ctx); - insertionPoint = insertionPoint.nextSibling; - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // otherwise search forward in the existing old children for an id set match - let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a potential match, remove the nodes until that point and morph - if (idSetMatch) { - insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); - morphOldNodeTo(idSetMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // no id set match found, so scan forward for a soft match for the current node - let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a soft match for the current node, morph - if (softMatch) { - insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); - morphOldNodeTo(softMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // abandon all hope of morphing, just insert the new child before the insertion point - // and move on - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.insertBefore(newChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - } - - // remove any remaining old nodes that didn't match up with new content - while (insertionPoint !== null) { - - let tempNode = insertionPoint; - insertionPoint = insertionPoint.nextSibling; - removeNode(tempNode, ctx); - } - } - - //============================================================================= - // Attribute Syncing Code - //============================================================================= - - /** - * @param attr {String} the attribute to be mutated - * @param to {Element} the element that is going to be updated - * @param updateType {("update"|"remove")} - * @param ctx the merge context - * @returns {boolean} true if the attribute should be ignored, false otherwise - */ - function ignoreAttribute(attr, to, updateType, ctx) { - if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ - return true; - } - return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; - } - - /** - * syncs a given node with another node, copying over all attributes and - * inner element state from the 'from' node to the 'to' node - * - * @param {Element} from the element to copy attributes & state from - * @param {Element} to the element to copy attributes & state to - * @param ctx the merge context - */ - function syncNodeFrom(from, to, ctx) { - let type = from.nodeType - - // if is an element type, sync the attributes from the - // new node into the new node - if (type === 1 /* element type */) { - const fromAttributes = from.attributes; - const toAttributes = to.attributes; - for (const fromAttribute of fromAttributes) { - if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { - continue; - } - if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { - to.setAttribute(fromAttribute.name, fromAttribute.value); - } - } - // iterate backwards to avoid skipping over items when a delete occurs - for (let i = toAttributes.length - 1; 0 <= i; i--) { - const toAttribute = toAttributes[i]; - if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { - continue; - } - if (!from.hasAttribute(toAttribute.name)) { - to.removeAttribute(toAttribute.name); - } - } - } - - // sync text nodes - if (type === 8 /* comment */ || type === 3 /* text */) { - if (to.nodeValue !== from.nodeValue) { - to.nodeValue = from.nodeValue; - } - } - - if (!ignoreValueOfActiveElement(to, ctx)) { - // sync input values - syncInputValue(from, to, ctx); - } - } - - /** - * @param from {Element} element to sync the value from - * @param to {Element} element to sync the value to - * @param attributeName {String} the attribute name - * @param ctx the merge context - */ - function syncBooleanAttribute(from, to, attributeName, ctx) { - if (from[attributeName] !== to[attributeName]) { - let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); - if (!ignoreUpdate) { - to[attributeName] = from[attributeName]; - } - if (from[attributeName]) { - if (!ignoreUpdate) { - to.setAttribute(attributeName, from[attributeName]); - } - } else { - if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { - to.removeAttribute(attributeName); - } - } - } - } - - /** - * NB: many bothans died to bring us information: - * - * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js - * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 - * - * @param from {Element} the element to sync the input value from - * @param to {Element} the element to sync the input value to - * @param ctx the merge context - */ - function syncInputValue(from, to, ctx) { - if (from instanceof HTMLInputElement && - to instanceof HTMLInputElement && - from.type !== 'file') { - - let fromValue = from.value; - let toValue = to.value; - - // sync boolean attributes - syncBooleanAttribute(from, to, 'checked', ctx); - syncBooleanAttribute(from, to, 'disabled', ctx); - - if (!from.hasAttribute('value')) { - if (!ignoreAttribute('value', to, 'remove', ctx)) { - to.value = ''; - to.removeAttribute('value'); - } - } else if (fromValue !== toValue) { - if (!ignoreAttribute('value', to, 'update', ctx)) { - to.setAttribute('value', fromValue); - to.value = fromValue; - } - } - } else if (from instanceof HTMLOptionElement) { - syncBooleanAttribute(from, to, 'selected', ctx) - } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { - let fromValue = from.value; - let toValue = to.value; - if (ignoreAttribute('value', to, 'update', ctx)) { - return; - } - if (fromValue !== toValue) { - to.value = fromValue; - } - if (to.firstChild && to.firstChild.nodeValue !== fromValue) { - to.firstChild.nodeValue = fromValue - } - } - } - - //============================================================================= - // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style - //============================================================================= - function handleHeadElement(newHeadTag, currentHead, ctx) { - - let added = [] - let removed = [] - let preserved = [] - let nodesToAppend = [] - - let headMergeStyle = ctx.head.style; - - // put all new head elements into a Map, by their outerHTML - let srcToNewHeadNodes = new Map(); - for (const newHeadChild of newHeadTag.children) { - srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); - } - - // for each elt in the current head - for (const currentHeadElt of currentHead.children) { - - // If the current head element is in the map - let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); - let isReAppended = ctx.head.shouldReAppend(currentHeadElt); - let isPreserved = ctx.head.shouldPreserve(currentHeadElt); - if (inNewContent || isPreserved) { - if (isReAppended) { - // remove the current version and let the new version replace it and re-execute - removed.push(currentHeadElt); - } else { - // this element already exists and should not be re-appended, so remove it from - // the new content map, preserving it in the DOM - srcToNewHeadNodes.delete(currentHeadElt.outerHTML); - preserved.push(currentHeadElt); - } - } else { - if (headMergeStyle === "append") { - // we are appending and this existing element is not new content - // so if and only if it is marked for re-append do we do anything - if (isReAppended) { - removed.push(currentHeadElt); - nodesToAppend.push(currentHeadElt); - } - } else { - // if this is a merge, we remove this content since it is not in the new head - if (ctx.head.shouldRemove(currentHeadElt) !== false) { - removed.push(currentHeadElt); - } - } - } - } - - // Push the remaining new head elements in the Map into the - // nodes to append to the head tag - nodesToAppend.push(...srcToNewHeadNodes.values()); - log("to append: ", nodesToAppend); - - let promises = []; - for (const newNode of nodesToAppend) { - log("adding: ", newNode); - let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; - log(newElt); - if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { - if (newElt.href || newElt.src) { - let resolve = null; - let promise = new Promise(function (_resolve) { - resolve = _resolve; - }); - newElt.addEventListener('load', function () { - resolve(); - }); - promises.push(promise); - } - currentHead.appendChild(newElt); - ctx.callbacks.afterNodeAdded(newElt); - added.push(newElt); - } - } - - // remove all removed elements, after we have appended the new elements to avoid - // additional network requests for things like style sheets - for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - currentHead.removeChild(removedElement); - ctx.callbacks.afterNodeRemoved(removedElement); - } - } - - ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); - return promises; - } - - //============================================================================= - // Misc - //============================================================================= - - function log() { - //console.log(arguments); - } - - function noOp() { - } - - /* - Deep merges the config object and the Idiomoroph.defaults object to - produce a final configuration object - */ - function mergeDefaults(config) { - let finalConfig = {}; - // copy top level stuff into final config - Object.assign(finalConfig, defaults); - Object.assign(finalConfig, config); - - // copy callbacks into final config (do this to deep merge the callbacks) - finalConfig.callbacks = {}; - Object.assign(finalConfig.callbacks, defaults.callbacks); - Object.assign(finalConfig.callbacks, config.callbacks); - - // copy head config into final config (do this to deep merge the head) - finalConfig.head = {}; - Object.assign(finalConfig.head, defaults.head); - Object.assign(finalConfig.head, config.head); - return finalConfig; - } - - function createMorphContext(oldNode, newContent, config) { - config = mergeDefaults(config); - return { - target: oldNode, - newContent: newContent, - config: config, - morphStyle: config.morphStyle, - ignoreActive: config.ignoreActive, - ignoreActiveValue: config.ignoreActiveValue, - idMap: createIdMap(oldNode, newContent), - deadIds: new Set(), - callbacks: config.callbacks, - head: config.head - } - } - - function isIdSetMatch(node1, node2, ctx) { - if (node1 == null || node2 == null) { - return false; - } - if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { - if (node1.id !== "" && node1.id === node2.id) { - return true; - } else { - return getIdIntersectionCount(ctx, node1, node2) > 0; - } - } - return false; - } - - function isSoftMatch(node1, node2) { - if (node1 == null || node2 == null) { - return false; - } - return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName - } - - function removeNodesBetween(startInclusive, endExclusive, ctx) { - while (startInclusive !== endExclusive) { - let tempNode = startInclusive; - startInclusive = startInclusive.nextSibling; - removeNode(tempNode, ctx); - } - removeIdsFromConsideration(ctx, endExclusive); - return endExclusive.nextSibling; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential id match - // for the newChild. We stop if we find a potential id match for the new child OR - // if the number of potential id matches we are discarding is greater than the - // potential id matches for the new child - //============================================================================= - function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - // max id matches we are willing to discard in our search - let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); - - let potentialMatch = null; - - // only search forward if there is a possibility of an id match - if (newChildPotentialIdCount > 0) { - let potentialMatch = insertionPoint; - // if there is a possibility of an id match, scan forward - // keep track of the potential id match count we are discarding (the - // newChildPotentialIdCount must be greater than this to make it likely - // worth it) - let otherMatchCount = 0; - while (potentialMatch != null) { - - // If we have an id match, return the current potential match - if (isIdSetMatch(newChild, potentialMatch, ctx)) { - return potentialMatch; - } - - // computer the other potential matches of this new content - otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); - if (otherMatchCount > newChildPotentialIdCount) { - // if we have more potential id matches in _other_ content, we - // do not have a good candidate for an id match, so return null - return null; - } - - // advanced to the next old content child - potentialMatch = potentialMatch.nextSibling; - } - } - return potentialMatch; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential soft match - // for the newChild. We stop if we find a potential soft match for the new child OR - // if we find a potential id match in the old parents children OR if we find two - // potential soft matches for the next two pieces of new content - //============================================================================= - function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - let potentialSoftMatch = insertionPoint; - let nextSibling = newChild.nextSibling; - let siblingSoftMatchCount = 0; - - while (potentialSoftMatch != null) { - - if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { - // the current potential soft match has a potential id set match with the remaining new - // content so bail out of looking - return null; - } - - // if we have a soft match with the current node, return it - if (isSoftMatch(newChild, potentialSoftMatch)) { - return potentialSoftMatch; - } - - if (isSoftMatch(nextSibling, potentialSoftMatch)) { - // the next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, bail to allow the siblings to soft match - // so that we don't consume future soft matches for the sake of the current node - if (siblingSoftMatchCount >= 2) { - return null; - } - } - - // advanced to the next old content child - potentialSoftMatch = potentialSoftMatch.nextSibling; - } - - return potentialSoftMatch; - } - - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { - let content = parser.parseFromString(newContent, "text/html"); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - content.generatedByIdiomorph = true; - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - htmlElement.generatedByIdiomorph = true; - return htmlElement; - } else { - return null; - } - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString("", "text/html"); - let content = responseDoc.body.querySelector('template').content; - content.generatedByIdiomorph = true; - return content - } - } - - function normalizeContent(newContent) { - if (newContent == null) { - // noinspection UnnecessaryLocalVariableJS - const dummyParent = document.createElement('div'); - return dummyParent; - } else if (newContent.generatedByIdiomorph) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return newContent; - } else if (newContent instanceof Node) { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement('div'); - dummyParent.append(newContent); - return dummyParent; - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement('div'); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - function insertSiblings(previousSibling, morphedNode, nextSibling) { - let stack = [] - let added = [] - while (previousSibling != null) { - stack.push(previousSibling); - previousSibling = previousSibling.previousSibling; - } - while (stack.length > 0) { - let node = stack.pop(); - added.push(node); // push added preceding siblings on in order and insert - morphedNode.parentElement.insertBefore(node, morphedNode); - } - added.push(morphedNode); - while (nextSibling != null) { - stack.push(nextSibling); - added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add - nextSibling = nextSibling.nextSibling; - } - while (stack.length > 0) { - morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); - } - return added; - } - - function findBestNodeMatch(newContent, oldNode, ctx) { - let currentElement; - currentElement = newContent.firstChild; - let bestElement = currentElement; - let score = 0; - while (currentElement) { - let newScore = scoreElement(currentElement, oldNode, ctx); - if (newScore > score) { - bestElement = currentElement; - score = newScore; - } - currentElement = currentElement.nextSibling; - } - return bestElement; - } - - function scoreElement(node1, node2, ctx) { - if (isSoftMatch(node1, node2)) { - return .5 + getIdIntersectionCount(ctx, node1, node2); - } - return 0; - } - - function removeNode(tempNode, ctx) { - removeIdsFromConsideration(ctx, tempNode) - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - - tempNode.remove(); - ctx.callbacks.afterNodeRemoved(tempNode); - } - - //============================================================================= - // ID Set Functions - //============================================================================= - - function isIdInConsideration(ctx, id) { - return !ctx.deadIds.has(id); - } - - function idIsWithinNode(ctx, id, targetNode) { - let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; - return idSet.has(id); - } - - function removeIdsFromConsideration(ctx, node) { - let idSet = ctx.idMap.get(node) || EMPTY_SET; - for (const id of idSet) { - ctx.deadIds.add(id); - } - } - - function getIdIntersectionCount(ctx, node1, node2) { - let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; - let matchCount = 0; - for (const id of sourceSet) { - // a potential match is an id in the source and potentialIdsSet, but - // that has not already been merged into the DOM - if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { - ++matchCount; - } - } - return matchCount; - } - - /** - * A bottom up algorithm that finds all elements with ids inside of the node - * argument and populates id sets for those nodes and all their parents, generating - * a set of ids contained within all nodes for the entire hierarchy in the DOM - * - * @param node {Element} - * @param {Map>} idMap - */ - function populateIdMapForNode(node, idMap) { - let nodeParent = node.parentElement; - // find all elements with an id property - let idElements = node.querySelectorAll('[id]'); - for (const elt of idElements) { - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current !== nodeParent && current != null) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); - } - idSet.add(elt.id); - current = current.parentElement; - } - } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {Map>} a map of nodes to id sets for the - */ - function createIdMap(oldContent, newContent) { - let idMap = new Map(); - populateIdMapForNode(oldContent, idMap); - populateIdMapForNode(newContent, idMap); - return idMap; - } - - //============================================================================= - // This is what ends up becoming the Idiomorph global object - //============================================================================= - return { - morph, - defaults - } - })(); + "use strict"; + + /** + * @typedef {object} MorphContext + * + * @property {Element} target + * @property {Element} newContent + * @property {ConfigInternal} config + * @property {ConfigInternal['morphStyle']} morphStyle + * @property {ConfigInternal['ignoreActive']} ignoreActive + * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {ConfigInternal['restoreFocus']} restoreFocus + * @property {Map>} idMap + * @property {Set} persistentIds + * @property {ConfigInternal['callbacks']} callbacks + * @property {ConfigInternal['head']} head + * @property {HTMLDivElement} pantry + * @property {Element[]} activeElementAndParents + */ + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + + const noOp = () => {}; + /** + * Default configuration values, updatable by users now + * @type {ConfigInternal} + */ + const defaults = { + morphStyle: "outerHTML", + callbacks: { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, + }, + head: { + style: "merge", + shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", + shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", + shouldRemove: noOp, + afterHeadMorphed: noOp, + }, + restoreFocus: true, + }; + + /** + * Core idiomorph function for morphing one DOM tree to another + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {Promise | Node[]} + */ + function morph(oldNode, newContent, config = {}) { + oldNode = normalizeElement(oldNode); + const newNode = normalizeParent(newContent); + const ctx = createMorphContext(oldNode, newNode, config); + + const morphedNodes = saveAndRestoreFocus(ctx, () => { + return withHeadBlocking( + ctx, + oldNode, + newNode, + /** @param {MorphContext} ctx */ (ctx) => { + if (ctx.morphStyle === "innerHTML") { + morphChildren(ctx, oldNode, newNode); + return Array.from(oldNode.childNodes); + } else { + return morphOuterHTML(ctx, oldNode, newNode); + } + }, + ); + }); + + ctx.pantry.remove(); + return morphedNodes; + } + + /** + * Morph just the outerHTML of the oldNode to the newContent + * We have to be careful because the oldNode could have siblings which need to be untouched + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @returns {Node[]} + */ + function morphOuterHTML(ctx, oldNode, newNode) { + const oldParent = normalizeParent(oldNode); + morphChildren( + ctx, + oldParent, + newNode, + // these two optional params are the secret sauce + oldNode, // start point for iteration + oldNode.nextSibling, // end point for iteration + ); + // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed. + return Array.from(oldParent.childNodes); + } + + /** + * @param {MorphContext} ctx + * @param {Function} fn + * @returns {Promise | Node[]} + */ + function saveAndRestoreFocus(ctx, fn) { + if (!ctx.config.restoreFocus) return fn(); + let activeElement = + /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( + document.activeElement + ); + + // don't bother if the active element is not an input or textarea + if ( + !( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement + ) + ) { + return fn(); + } + + const { id: activeElementId, selectionStart, selectionEnd } = activeElement; + + const results = fn(); + + if ( + activeElementId && + activeElementId !== document.activeElement?.getAttribute("id") + ) { + activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`); + activeElement?.focus(); + } + if (activeElement && !activeElement.selectionEnd && selectionEnd) { + activeElement.setSelectionRange(selectionStart, selectionEnd); + } + + return results; + } + + const morphChildren = (function () { + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm: + * - for each node in the new content: + * - search self and siblings for an id set match, falling back to a soft match + * - if match found + * - remove any nodes up to the match: + * - pantry persistent nodes + * - delete the rest + * - morph the match + * - elsif no match found, and node is persistent + * - find its match by querying the old root (future) and pantry (past) + * - move it and its children here + * - morph it + * - else + * - create a new node from scratch as a last result + * + * @param {MorphContext} ctx the merge context + * @param {Element} oldParent the old content that we are merging the new content into + * @param {Element} newParent the parent element of the new content + * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) + * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) + */ + function morphChildren( + ctx, + oldParent, + newParent, + insertionPoint = null, + endPoint = null, + ) { + // normalize + if ( + oldParent instanceof HTMLTemplateElement && + newParent instanceof HTMLTemplateElement + ) { + // @ts-ignore we can pretend the DocumentFragment is an Element + oldParent = oldParent.content; + // @ts-ignore ditto + newParent = newParent.content; + } + insertionPoint ||= oldParent.firstChild; + + // run through all the new content + for (const newChild of newParent.childNodes) { + // once we reach the end of the old parent content skip to the end and insert the rest + if (insertionPoint && insertionPoint != endPoint) { + const bestMatch = findBestMatch( + ctx, + newChild, + insertionPoint, + endPoint, + ); + if (bestMatch) { + // if the node to morph is not at the insertion point then remove/move up to it + if (bestMatch !== insertionPoint) { + removeNodesBetween(ctx, insertionPoint, bestMatch); + } + morphNode(bestMatch, newChild, ctx); + insertionPoint = bestMatch.nextSibling; + continue; + } + } + + // if the matching node is elsewhere in the original content + if (newChild instanceof Element) { + // we can pretend the id is non-null because the next `.has` line will reject it if not + const newChildId = /** @type {String} */ ( + newChild.getAttribute("id") + ); + if (ctx.persistentIds.has(newChildId)) { + // move it and all its children here and morph + const movedChild = moveBeforeById( + oldParent, + newChildId, + insertionPoint, + ctx, + ); + morphNode(movedChild, newChild, ctx); + insertionPoint = movedChild.nextSibling; + continue; + } + } + + // last resort: insert the new node from scratch + const insertedNode = createNode( + oldParent, + newChild, + insertionPoint, + ctx, + ); + // could be null if beforeNodeAdded prevented insertion + if (insertedNode) { + insertionPoint = insertedNode.nextSibling; + } + } + + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint && insertionPoint != endPoint) { + const tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(ctx, tempNode); + } + } + + /** + * This performs the action of inserting a new node while handling situations where the node contains + * elements with persistent ids and possible state info we can still preserve by moving in and then morphing + * + * @param {Element} oldParent + * @param {Node} newChild + * @param {Node|null} insertionPoint + * @param {MorphContext} ctx + * @returns {Node|null} + */ + function createNode(oldParent, newChild, insertionPoint, ctx) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; + if (ctx.idMap.has(newChild)) { + // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm + const newEmptyChild = document.createElement( + /** @type {Element} */ (newChild).tagName, + ); + oldParent.insertBefore(newEmptyChild, insertionPoint); + morphNode(newEmptyChild, newChild, ctx); + ctx.callbacks.afterNodeAdded(newEmptyChild); + return newEmptyChild; + } else { + // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants + const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent + oldParent.insertBefore(newClonedChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newClonedChild); + return newClonedChild; + } + } + + //============================================================================= + // Matching Functions + //============================================================================= + const findBestMatch = (function () { + /** + * Scans forward from the startPoint to the endPoint looking for a match + * for the node. It looks for an id set match first, then a soft match. + * We abort softmatching if we find two future soft matches, to reduce churn. + * @param {Node} node + * @param {MorphContext} ctx + * @param {Node | null} startPoint + * @param {Node | null} endPoint + * @returns {Node | null} + */ + function findBestMatch(ctx, node, startPoint, endPoint) { + let softMatch = null; + let nextSibling = node.nextSibling; + let siblingSoftMatchCount = 0; + + let cursor = startPoint; + while (cursor && cursor != endPoint) { + // soft matching is a prerequisite for id set matching + if (isSoftMatch(cursor, node)) { + if (isIdSetMatch(ctx, cursor, node)) { + return cursor; // found an id set match, we're done! + } + + // we haven't yet saved a soft match fallback + if (softMatch === null) { + // the current soft match will hard match something else in the future, leave it + if (!ctx.idMap.has(cursor)) { + // save this as the fallback if we get through the loop without finding a hard match + softMatch = cursor; + } + } + } + if ( + softMatch === null && + nextSibling && + isSoftMatch(cursor, nextSibling) + ) { + // The next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + + // If there are two future soft matches, block soft matching for this node to allow + // future siblings to soft match. This is to reduce churn in the DOM when an element + // is prepended. + if (siblingSoftMatchCount >= 2) { + softMatch = undefined; + } + } + + // if the current node contains active element, stop looking for better future matches, + // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus + // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion + if (ctx.activeElementAndParents.includes(cursor)) break; + + cursor = cursor.nextSibling; + } + + return softMatch || null; + } + + /** + * + * @param {MorphContext} ctx + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isIdSetMatch(ctx, oldNode, newNode) { + let oldSet = ctx.idMap.get(oldNode); + let newSet = ctx.idMap.get(newNode); + + if (!newSet || !oldSet) return false; + + for (const id of oldSet) { + // a potential match is an id in the new and old nodes that + // has not already been merged into the DOM + // But the newNode content we call this on has not been + // merged yet and we don't allow duplicate IDs so it is simple + if (newSet.has(id)) { + return true; + } + } + return false; + } + + /** + * + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isSoftMatch(oldNode, newNode) { + // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + return ( + oldElt.nodeType === newElt.nodeType && + oldElt.tagName === newElt.tagName && + // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. + // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, + // its not persistent, and new nodes can't have any hidden state. + // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment + (!oldElt.getAttribute?.("id") || + oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) + ); + } + + return findBestMatch; + })(); + + //============================================================================= + // DOM Manipulation Functions + //============================================================================= + + /** + * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: + * - Persistent nodes will be moved to the pantry for later reuse + * - Other nodes will have their hooks called, and then are removed + * @param {MorphContext} ctx + * @param {Node} node + */ + function removeNode(ctx, node) { + // are we going to id set match this later? + if (ctx.idMap.has(node)) { + // skip callbacks and move to pantry + moveBefore(ctx.pantry, node, null); + } else { + // remove for realsies + if (ctx.callbacks.beforeNodeRemoved(node) === false) return; + node.parentNode?.removeChild(node); + ctx.callbacks.afterNodeRemoved(node); + } + } + + /** + * Remove nodes between the start and end nodes + * @param {MorphContext} ctx + * @param {Node} startInclusive + * @param {Node} endExclusive + * @returns {Node|null} + */ + function removeNodesBetween(ctx, startInclusive, endExclusive) { + /** @type {Node | null} */ + let cursor = startInclusive; + // remove nodes until the endExclusive node + while (cursor && cursor !== endExclusive) { + let tempNode = /** @type {Node} */ (cursor); + cursor = cursor.nextSibling; + removeNode(ctx, tempNode); + } + return cursor; + } + + /** + * Search for an element by id within the document and pantry, and move it using moveBefore. + * + * @param {Element} parentNode - The parent node to which the element will be moved. + * @param {string} id - The ID of the element to be moved. + * @param {Node | null} after - The reference node to insert the element before. + * If `null`, the element is appended as the last child. + * @param {MorphContext} ctx + * @returns {Element} The found element + */ + function moveBeforeById(parentNode, id, after, ctx) { + const target = + /** @type {Element} - will always be found */ + ( + // ctx.target.id unsafe because of form input shadowing + // ctx.target could be a document fragment which doesn't have `getAttribute` + (ctx.target.getAttribute?.("id") === id && ctx.target) || + ctx.target.querySelector(`[id="${id}"]`) || + ctx.pantry.querySelector(`[id="${id}"]`) + ); + removeElementFromAncestorsIdMaps(target, ctx); + moveBefore(parentNode, target, after); + return target; + } + + /** + * Removes an element from its ancestors' id maps. This is needed when an element is moved from the + * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the + * pantry rather than being deleted, preventing their removal hooks from being called. + * + * @param {Element} element - element to remove from its ancestors' id maps + * @param {MorphContext} ctx + */ + function removeElementFromAncestorsIdMaps(element, ctx) { + // we know id is non-null String, because this function is only called on elements with ids + const id = /** @type {String} */ (element.getAttribute("id")); + /** @ts-ignore - safe to loop in this way **/ + while ((element = element.parentNode)) { + let idSet = ctx.idMap.get(element); + if (idSet) { + idSet.delete(id); + if (!idSet.size) { + ctx.idMap.delete(element); + } + } + } + } + + /** + * Moves an element before another element within the same parent. + * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. + * This is essentialy a forward-compat wrapper. + * + * @param {Element} parentNode - The parent node containing the after element. + * @param {Node} element - The element to be moved. + * @param {Node | null} after - The reference node to insert `element` before. + * If `null`, `element` is appended as the last child. + */ + function moveBefore(parentNode, element, after) { + // @ts-ignore - use proposed moveBefore feature + if (parentNode.moveBefore) { + try { + // @ts-ignore - use proposed moveBefore feature + parentNode.moveBefore(element, after); + } catch (e) { + // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry + parentNode.insertBefore(element, after); + } + } else { + parentNode.insertBefore(element, after); + } + } + + return morphChildren; + })(); + + //============================================================================= + // Single Node Morphing Code + //============================================================================= + const morphNode = (function () { + /** + * @param {Node} oldNode root node to merge content into + * @param {Node} newContent new content to merge + * @param {MorphContext} ctx the merge context + * @returns {Node | null} the element that ended up in the DOM + */ + function morphNode(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) { + // don't morph focused element + return null; + } + + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { + return oldNode; + } + + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) { + // ignore the head element + } else if ( + oldNode instanceof HTMLHeadElement && + ctx.head.style !== "morph" + ) { + // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above + handleHeadElement( + oldNode, + /** @type {HTMLHeadElement} */ (newContent), + ctx, + ); + } else { + morphAttributes(oldNode, newContent, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + // @ts-ignore newContent can be a node here because .firstChild will be null + morphChildren(ctx, oldNode, newContent); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + + /** + * syncs the oldNode to the newNode, copying over all attributes and + * inner element state from the newNode to the oldNode + * + * @param {Node} oldNode the node to copy attributes & state to + * @param {Node} newNode the node to copy attributes & state from + * @param {MorphContext} ctx the merge context + */ + function morphAttributes(oldNode, newNode, ctx) { + let type = newNode.nodeType; + + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + const oldAttributes = oldElt.attributes; + const newAttributes = newElt.attributes; + for (const newAttribute of newAttributes) { + if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { + continue; + } + if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { + oldElt.setAttribute(newAttribute.name, newAttribute.value); + } + } + // iterate backwards to avoid skipping over items when a delete occurs + for (let i = oldAttributes.length - 1; 0 <= i; i--) { + const oldAttribute = oldAttributes[i]; + + // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe + // e.g. custom element attribute callbacks can remove other attributes + if (!oldAttribute) continue; + + if (!newElt.hasAttribute(oldAttribute.name)) { + if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { + continue; + } + oldElt.removeAttribute(oldAttribute.name); + } + } + + if (!ignoreValueOfActiveElement(oldElt, ctx)) { + syncInputValue(oldElt, newElt, ctx); + } + } + + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + } + } + + /** + * NB: many bothans died to bring us information: + * + * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + * + * @param {Element} oldElement the element to sync the input value to + * @param {Element} newElement the element to sync the input value from + * @param {MorphContext} ctx the merge context + */ + function syncInputValue(oldElement, newElement, ctx) { + if ( + oldElement instanceof HTMLInputElement && + newElement instanceof HTMLInputElement && + newElement.type !== "file" + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + + // sync boolean attributes + syncBooleanAttribute(oldElement, newElement, "checked", ctx); + syncBooleanAttribute(oldElement, newElement, "disabled", ctx); + + if (!newElement.hasAttribute("value")) { + if (!ignoreAttribute("value", oldElement, "remove", ctx)) { + oldElement.value = ""; + oldElement.removeAttribute("value"); + } + } else if (oldValue !== newValue) { + if (!ignoreAttribute("value", oldElement, "update", ctx)) { + oldElement.setAttribute("value", newValue); + oldElement.value = newValue; + } + } + // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? + // did I break something? + } else if ( + oldElement instanceof HTMLOptionElement && + newElement instanceof HTMLOptionElement + ) { + syncBooleanAttribute(oldElement, newElement, "selected", ctx); + } else if ( + oldElement instanceof HTMLTextAreaElement && + newElement instanceof HTMLTextAreaElement + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + if (ignoreAttribute("value", oldElement, "update", ctx)) { + return; + } + if (newValue !== oldValue) { + oldElement.value = newValue; + } + if ( + oldElement.firstChild && + oldElement.firstChild.nodeValue !== newValue + ) { + oldElement.firstChild.nodeValue = newValue; + } + } + } + + /** + * @param {Element} oldElement element to write the value to + * @param {Element} newElement element to read the value from + * @param {string} attributeName the attribute name + * @param {MorphContext} ctx the merge context + */ + function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + const newLiveValue = newElement[attributeName], + // @ts-ignore ditto + oldLiveValue = oldElement[attributeName]; + if (newLiveValue !== oldLiveValue) { + const ignoreUpdate = ignoreAttribute( + attributeName, + oldElement, + "update", + ctx, + ); + if (!ignoreUpdate) { + // update attribute's associated DOM property + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + oldElement[attributeName] = newElement[attributeName]; + } + if (newLiveValue) { + if (!ignoreUpdate) { + // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML + // this is the correct way to set a boolean attribute to "true" + oldElement.setAttribute(attributeName, ""); + } + } else { + if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { + oldElement.removeAttribute(attributeName); + } + } + } + } + + /** + * @param {string} attr the attribute to be mutated + * @param {Element} element the element that is going to be updated + * @param {"update" | "remove"} updateType + * @param {MorphContext} ctx the merge context + * @returns {boolean} true if the attribute should be ignored, false otherwise + */ + function ignoreAttribute(attr, element, updateType, ctx) { + if ( + attr === "value" && + ctx.ignoreActiveValue && + element === document.activeElement + ) { + return true; + } + return ( + ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === + false + ); + } + + /** + * @param {Node} possibleActiveElement + * @param {MorphContext} ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ( + !!ctx.ignoreActiveValue && + possibleActiveElement === document.activeElement && + possibleActiveElement !== document.body + ); + } + + return morphNode; + })(); + + //============================================================================= + // Head Management Functions + //============================================================================= + /** + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @param {function} callback + * @returns {Node[] | Promise} + */ + function withHeadBlocking(ctx, oldNode, newNode, callback) { + if (ctx.head.block) { + const oldHead = oldNode.querySelector("head"); + const newHead = newNode.querySelector("head"); + if (oldHead && newHead) { + const promises = handleHeadElement(oldHead, newHead, ctx); + // when head promises resolve, proceed ignoring the head tag + return Promise.all(promises).then(() => { + const newCtx = Object.assign(ctx, { + head: { + block: false, + ignore: true, + }, + }); + return callback(newCtx); + }); + } + } + // just proceed if we not head blocking + return callback(ctx); + } + + /** + * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + * + * @param {Element} oldHead + * @param {Element} newHead + * @param {MorphContext} ctx + * @returns {Promise[]} + */ + function handleHeadElement(oldHead, newHead, ctx) { + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHead.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + + // for each elt in the current head + for (const currentHeadElt of oldHead.children) { + // If the current head element is in the map + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (ctx.head.style === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + + // Push the remaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); + + let promises = []; + for (const newNode of nodesToAppend) { + // TODO: This could theoretically be null, based on type + let newElt = /** @type {ChildNode} */ ( + document.createRange().createContextualFragment(newNode.outerHTML) + .firstChild + ); + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if ( + ("href" in newElt && newElt.href) || + ("src" in newElt && newElt.src) + ) { + /** @type {(result?: any) => void} */ let resolve; + let promise = new Promise(function (_resolve) { + resolve = _resolve; + }); + newElt.addEventListener("load", function () { + resolve(); + }); + promises.push(promise); + } + oldHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + oldHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + + ctx.head.afterHeadMorphed(oldHead, { + added: added, + kept: preserved, + removed: removed, + }); + return promises; + } + + //============================================================================= + // Create Morph Context Functions + //============================================================================= + const createMorphContext = (function () { + /** + * + * @param {Element} oldNode + * @param {Element} newContent + * @param {Config} config + * @returns {MorphContext} + */ + function createMorphContext(oldNode, newContent, config) { + const { persistentIds, idMap } = createIdMaps(oldNode, newContent); + + const mergedConfig = mergeDefaults(config); + const morphStyle = mergedConfig.morphStyle || "outerHTML"; + if (!["innerHTML", "outerHTML"].includes(morphStyle)) { + throw `Do not understand how to morph style ${morphStyle}`; + } + + return { + target: oldNode, + newContent: newContent, + config: mergedConfig, + morphStyle: morphStyle, + ignoreActive: mergedConfig.ignoreActive, + ignoreActiveValue: mergedConfig.ignoreActiveValue, + restoreFocus: mergedConfig.restoreFocus, + idMap: idMap, + persistentIds: persistentIds, + pantry: createPantry(), + activeElementAndParents: createActiveElementAndParents(oldNode), + callbacks: mergedConfig.callbacks, + head: mergedConfig.head, + }; + } + + /** + * Deep merges the config object and the Idiomorph.defaults object to + * produce a final configuration object + * @param {Config} config + * @returns {ConfigInternal} + */ + function mergeDefaults(config) { + let finalConfig = Object.assign({}, defaults); + + // copy top level stuff into final config + Object.assign(finalConfig, config); + + // copy callbacks into final config (do this to deep merge the callbacks) + finalConfig.callbacks = Object.assign( + {}, + defaults.callbacks, + config.callbacks, + ); + + // copy head config into final config (do this to deep merge the head) + finalConfig.head = Object.assign({}, defaults.head, config.head); + + return finalConfig; + } + + /** + * @returns {HTMLDivElement} + */ + function createPantry() { + const pantry = document.createElement("div"); + pantry.hidden = true; + document.body.insertAdjacentElement("afterend", pantry); + return pantry; + } + + /** + * @param {Element} oldNode + * @returns {Element[]} + */ + function createActiveElementAndParents(oldNode) { + /** @type {Element[]} */ + let activeElementAndParents = []; + let elt = document.activeElement; + if (elt?.tagName !== "BODY" && oldNode.contains(elt)) { + while (elt) { + activeElementAndParents.push(elt); + if (elt === oldNode) break; + elt = elt.parentElement; + } + } + return activeElementAndParents; + } + + /** + * Returns all elements with an ID contained within the root element and its descendants + * + * @param {Element} root + * @returns {Element[]} + */ + function findIdElements(root) { + let elements = Array.from(root.querySelectorAll("[id]")); + // root could be a document fragment which doesn't have `getAttribute` + if (root.getAttribute?.("id")) { + elements.push(root); + } + return elements; + } + + /** + * A bottom-up algorithm that populates a map of Element -> IdSet. + * The idSet for a given element is the set of all IDs contained within its subtree. + * As an optimzation, we filter these IDs through the given list of persistent IDs, + * because we don't need to bother considering IDed elements that won't be in the new content. + * + * @param {Map>} idMap + * @param {Set} persistentIds + * @param {Element} root + * @param {Element[]} elements + */ + function populateIdMapWithTree(idMap, persistentIds, root, elements) { + for (const elt of elements) { + // we can pretend id is non-null String, because the .has line will reject it immediately if not + const id = /** @type {String} */ (elt.getAttribute("id")); + if (persistentIds.has(id)) { + /** @type {Element|null} */ + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(id); + + if (current === root) break; + current = current.parentElement; + } + } + } + } + + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {IdSets} + */ + function createIdMaps(oldContent, newContent) { + const oldIdElements = findIdElements(oldContent); + const newIdElements = findIdElements(newContent); + + const persistentIds = createPersistentIds(oldIdElements, newIdElements); + + /** @type {Map>} */ + let idMap = new Map(); + populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); + + /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ + const newRoot = newContent.__idiomorphRoot || newContent; + populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); + + return { persistentIds, idMap }; + } + + /** + * This function computes the set of ids that persist between the two contents excluding duplicates + * + * @param {Element[]} oldIdElements + * @param {Element[]} newIdElements + * @returns {Set} + */ + function createPersistentIds(oldIdElements, newIdElements) { + let duplicateIds = new Set(); + + /** @type {Map} */ + let oldIdTagNameMap = new Map(); + for (const { id, tagName } of oldIdElements) { + if (oldIdTagNameMap.has(id)) { + duplicateIds.add(id); + } else { + oldIdTagNameMap.set(id, tagName); + } + } + + let persistentIds = new Set(); + for (const { id, tagName } of newIdElements) { + if (persistentIds.has(id)) { + duplicateIds.add(id); + } else if (oldIdTagNameMap.get(id) === tagName) { + persistentIds.add(id); + } + // skip if tag types mismatch because its not possible to morph one tag into another + } + + for (const id of duplicateIds) { + persistentIds.delete(id); + } + return persistentIds; + } + + return createMorphContext; + })(); + + //============================================================================= + // HTML Normalization Functions + //============================================================================= + const { normalizeElement, normalizeParent } = (function () { + /** @type {WeakSet} */ + const generatedByIdiomorph = new WeakSet(); + + /** + * + * @param {Element | Document} content + * @returns {Element} + */ + function normalizeElement(content) { + if (content instanceof Document) { + return content.documentElement; + } else { + return content; + } + } + + /** + * + * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent + * @returns {Element} + */ + function normalizeParent(newContent) { + if (newContent == null) { + return document.createElement("div"); // dummy parent element + } else if (typeof newContent === "string") { + return normalizeParent(parseContent(newContent)); + } else if ( + generatedByIdiomorph.has(/** @type {Element} */ (newContent)) + ) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return /** @type {Element} */ (newContent); + } else if (newContent instanceof Node) { + if (newContent.parentNode) { + // we can't use the parent directly because newContent may have siblings + // that we don't want in the morph, and reparenting might be expensive (TODO is it?), + // so instead we create a fake parent node that only sees a slice of its children. + /** @type {Element} */ + return /** @type {any} */ (new SlicedParentNode(newContent)); + } else { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement("div"); + dummyParent.append(newContent); + return dummyParent; + } + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement("div"); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } + } + + /** + * A fake duck-typed parent element to wrap a single node, without actually reparenting it. + * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved + * or replaced with one or more elements during the morph. This class effectively allows us a window into + * a slice of a node's children. + * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) + */ + class SlicedParentNode { + /** @param {Node} node */ + constructor(node) { + this.originalNode = node; + this.realParentNode = /** @type {Element} */ (node.parentNode); + this.previousSibling = node.previousSibling; + this.nextSibling = node.nextSibling; + } + + /** @returns {Node[]} */ + get childNodes() { + // return slice of realParent's current childNodes, based on previousSibling and nextSibling + const nodes = []; + let cursor = this.previousSibling + ? this.previousSibling.nextSibling + : this.realParentNode.firstChild; + while (cursor && cursor != this.nextSibling) { + nodes.push(cursor); + cursor = cursor.nextSibling; + } + return nodes; + } + + /** + * @param {string} selector + * @returns {Element[]} + */ + querySelectorAll(selector) { + return this.childNodes.reduce((results, node) => { + if (node instanceof Element) { + if (node.matches(selector)) results.push(node); + const nodeList = node.querySelectorAll(selector); + for (let i = 0; i < nodeList.length; i++) { + results.push(nodeList[i]); + } + } + return results; + }, /** @type {Element[]} */ ([])); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + insertBefore(node, referenceNode) { + return this.realParentNode.insertBefore(node, referenceNode); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + moveBefore(node, referenceNode) { + // @ts-ignore - use new moveBefore feature + return this.realParentNode.moveBefore(node, referenceNode); + } + + /** + * for later use with populateIdMapWithTree to halt upwards iteration + * @returns {Node} + */ + get __idiomorphRoot() { + return this.originalNode; + } + } + + /** + * + * @param {string} newContent + * @returns {Node | null | DocumentFragment} + */ + function parseContent(newContent) { + let parser = new DOMParser(); + + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace( + /]*>|>)([\s\S]*?)<\/svg>/gim, + "", + ); + + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if ( + contentWithSvgsRemoved.match(/<\/html>/) || + contentWithSvgsRemoved.match(/<\/head>/) || + contentWithSvgsRemoved.match(/<\/body>/) + ) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + generatedByIdiomorph.add(content); + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + generatedByIdiomorph.add(htmlElement); + } + return htmlElement; + } + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString( + "", + "text/html", + ); + let content = /** @type {HTMLTemplateElement} */ ( + responseDoc.body.querySelector("template") + ).content; + generatedByIdiomorph.add(content); + return content; + } + } + + return { normalizeElement, normalizeParent }; + })(); + + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults, + }; +})(); From 0691153101340bb0e121ecc3bf456eaee90a2fda Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Fri, 6 Feb 2026 19:17:49 -0800 Subject: [PATCH 5/5] Stabilize IDs for outfit viewer component --- app/views/application/_outfit_viewer.html.haml | 2 ++ app/views/items/show.html.haml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/application/_outfit_viewer.html.haml b/app/views/application/_outfit_viewer.html.haml index 52f2e386..884646e0 100644 --- a/app/views/application/_outfit_viewer.html.haml +++ b/app/views/application/_outfit_viewer.html.haml @@ -1,4 +1,5 @@ - html_options = {} unless defined? html_options +- viewer_id = html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}" = content_tag "outfit-viewer", **html_options do .loading-indicator= render partial: "hanger_spinner" @@ -14,6 +15,7 @@ - outfit.visible_layers.each do |swf_asset| %outfit-layer{ + id: "#{viewer_id}-layer-#{swf_asset.id}", data: { "asset-id": swf_asset.id, "zone": swf_asset.zone.label, diff --git a/app/views/items/show.html.haml b/app/views/items/show.html.haml index 37879734..56763663 100644 --- a/app/views/items/show.html.haml +++ b/app/views/items/show.html.haml @@ -16,7 +16,7 @@ = turbo_frame_tag "item-preview" do .preview-area - = outfit_viewer @preview_outfit + = outfit_viewer @preview_outfit, id: "item-preview-outfit-viewer" .error-indicator 💥 We couldn't load all of this outfit. Try again? = link_to wardrobe_path(params: @preview_outfit.wardrobe_params),